10 Commits

Author SHA1 Message Date
24590cc351 deploy: static binary and a compose project name that does not collide
Two failures from the first real deploy, both repo-side:

1. The container logged "exec /usr/bin/resume-server: no such file or
   directory" and restarted until the health gate failed. The build host has
   cgo enabled, so the binary carried the nix glibc interpreter, and the
   distroless/static image has no dynamic loader. Every SQLite path already
   uses pure-Go modernc.org/sqlite, so build with CGO_ENABLED=0 and ship a
   static binary.

2. Compose warned about orphan containers ai-resume-backend-1 and
   ai-resume-db-1: /home/sam/deployment/ai-resume on .13 already owns the
   `ai-resume` compose project (on the ai-mesh network). Sharing the name makes
   each stack visible as orphans to the other, so a `compose down
   --remove-orphans` in either could stop the other. Run the site as project
   `ai-resume-site`; the image tag stays ai-resume:local and the port binding,
   mem_limit and memswap_limit are untouched.
2026-10-26 20:53:25 +11:00
c95e5361e3 deploy: pin the compose network to an explicit /28
The first deploy failed before the health gate:

    Network ai-resume_default Error Error response from daemon:
    all predefined address pools have been fully subnetted

.13's docker daemon has 31 bridge networks already, which exhausts its
default-address-pools, so any new implicit compose network fails to create.
Pinning 10.89.0.0/28 (no route, bridge or LAN overlap on .13) makes this
project independent of that host condition without touching another project,
container or the daemon config. mem_limit, memswap_limit and the 127.0.0.1
port binding are unchanged.
2026-10-26 20:51:17 +11:00
2906683f44 deploy: add deploy.sh (release, rollback, status) for Phase 3 section 3.1
The release directory under /home/sam/deployment/resume is the rollback unit;
deploy.sh stages releases/<timestamp>-<sha>/, flips `current` atomically,
builds the image from that directory and gates on /healthz, restoring the
previous release on any failure.

The rehearsal recipe in the header is kept as comments only; the pasted spec
lost the comment prefixes on the multi-line printf, which would have made the
script exit 1 on startup. It now uses \n escapes on one commented line.
2026-10-26 20:49:53 +11:00
c2b4e33897 Merge Phase 2: Mode A static site
DoD satisfied (evidence in AUD-142): A1 with no AI services, one binary,
deterministic templ generate, 6 L1 + 6 L2 + artefact pages, FTS5 search,
Lighthouse 100/100 mobile and desktop, <100ms first byte, stage panel driven
by route or stream, print legible.
2026-10-26 20:43:03 +11:00
84fc585c91 Phase 2: Mode A static site — build, serve, search, stage panel
One Go binary serves a pre-rendered site. No Node, no bundler, no node_modules:
htmx and the SSE extension are vendored files, Markdown and code highlighting
run at build time (goldmark + chroma), and the server only writes files.

Implements docs/phase-2-static-site.md:

  2.1 service skeleton   cmd/build (content -> dist), cmd/server (dist -> HTTP),
                         env-configured, 127.0.0.1:8096, /healthz with separate
                         static / ai / search readiness, --health-check probe,
                         deploy/compose.yml + deploy/Dockerfile at 120m
  2.2 rendering          templ components (Layout, Nav, PillarCard, Stage,
                         EvidenceTable, CodeBlock, Timeline, Footer);
                         `make generate` is a build step and is byte-stable
  2.3 navigation         six-area rail, <details> disclosure under 900px,
                         breadcrumbs, aria-current, L1/L2/L3 with artefact pages
  2.4 stage panel        internal/stage owns the contract; RouteDriver (fragment
                         swap) and SSEDriver (event stream) differ only in the
                         Binding, never in layout. fragment ids live in the
                         contract so route files, links and streamed frames agree
  2.5 search             SQLite FTS5 over titles and body, rebuilt by the build;
                         one handler returns a page without HTMX, a fragment with
                         it. Visitors' FTS operators are never honoured
  2.6 quality floor      print stylesheet, JS-off navigation, focus states,
                         semantic landmarks, dark default; security headers

Definition of Done evidence (see issue AUD-142):
  A1  all pages 200 in a network namespace with no external route
  one binary serves the site; make dist writes 45 pages, 86 fragments, 40 docs
  templ generate is deterministic (md5 identical across runs)
  6 L1 + 6 L2 pages, artefact pages reachable in <= 3 clicks
  FTS5 returns sensible hits for zigbee / signage / routing (tested)
  Lighthouse 100 performance and 100 accessibility, mobile and desktop
  first byte well inside 100ms over loopback (test)
  panel renders from a route driver and re-points at the stream untouched
  print legible: /edge/ renders to a 3-page PDF with the print stylesheet

Also fixes found while finishing the build:
  - reject "." / ".." path segments before ServeMux redirects them into a file
    outside the asset directory (/static/../build.json now 404, was 307->200)
  - make test is the real gate: go test ./... plus the validator self-test
2026-10-26 20:42:52 +11:00
fd9974d07e Merge branch 'main' into feat/phase-2-static-site 2026-10-26 20:01:38 +11:00
ea2c722816 WIP Phase 2: content loader, stage contract, search index, templ components 2026-10-26 20:01:38 +11:00
72edfec59c Phase 1: precise container/host wording on platform L1/L2
'The spine' records 50+ containers across 3 hosts and a 4-host estate; the
pages asserted containers 'across four hosts'. Reworded so the container
count and the host count are each stated without implying a distribution
the sources do not give. No metric value changed.

DoD: schema valid (make validate clean), metrics still trace to the
approved chip set.
2026-10-26 19:51:22 +11:00
Documenter
00fa15f98f Phase 1: content source of truth — 40 documents across six areas
Satisfies the Phase 1 Definition of Done:

- content/ validates cleanly against the frozen schema. `make validate`
  reports OK: 40 document(s) validated, 0 violations.
- Six areas exist, each with an L1 entry: ai-overview, platform-overview,
  edge-overview, products-overview, history-overview, about-overview.
- All six areas have an L2 entry: ai-systems, platform-estate, edge-fleet,
  products-business, history-engagements, about-narrative.
- 28 L3 artefact stubs under content/<area>/l3/. Every artefacts[].url in
  every document resolves to one of them; the validator proves there are no
  dangling references (and a deliberately broken copy exits 1).
- Every metric value traces to a source line in the evidence notes or the
  approved metric set in reference/content-map.md (per-metric citations are
  recorded in the AUD-131 completion comment).
- Every document carries confidence and last_verified=2026-10-26; the
  validator rejects a missing or non-canonical value.
- docs/content-redaction.md records the redaction pass: identifiers, third
  party personal data, credentials and infrastructure detail excluded, with
  reasons, plus the two numbers that are reported rather than smoothed.

Honesty positions held: 3 businesses active (12 adoptions over time),
Where-Woof deployed and pre-market, failures kept in (edge-failures), no
promotion of in-progress work to delivered.

No site code, no templates, no CSS, no rendering (Phase 2).
2026-10-26 17:30:35 +11:00
0eb151117e Phase 1a: content schema validator (freeze the schema)
Implements AGENTS.md section 7 front-matter schema as a single-file,
standard-library-only Python validator; no third-party YAML dependency
and no framework. Adds the `make validate` gate, a 20-case fixture set
and docs/content-schema.md.

DoD satisfied:
- make validate runs and exits 0 on an empty/clean content/ tree
  (missing dir and empty dir both exit 0; fixture ok-empty covers it).
- Every violation class has a fixture under test/fixtures/fail-*; each
  exits non-zero with one line per violation (file:line: field: rule).
  `make test` runs all 20 cases: 20/20 pass.
- Dangling artefact reference is fatal (fail-dangling-artefact;
  artefacts[].url must resolve to an existing in-repo L3 stub or asset).
- Unknown front-matter field is fatal (fail-unknown-field), including
  unknown keys nested inside artefacts[]/metrics[].
- docs/content-schema.md committed; states the frozen schema, links to
  AGENTS.md section 7, and records that any later schema change is a
  breaking change requiring Sam's approval.
- Scope respected: no content authored, no rendering, no site code.

Files: Makefile, tools/validate_content.py, test/fixtures/**,
docs/content-schema.md, .gitignore (__pycache__).
2026-10-26 17:26:00 +11:00
173 changed files with 10499 additions and 1713 deletions

5
.gitignore vendored
View File

@@ -1,4 +1,9 @@
# python
__pycache__/
*.pyc
# build output
/bin/
/dist/
/current/
*.test

106
Makefile Normal file
View File

@@ -0,0 +1,106 @@
# Ai Resume - build entry points.
#
# `make validate` is the content gate: it is what CI and deploy.sh call.
# It exits 0 only on a clean content tree.
#
# `make build` is the site gate: it generates the templ components, compiles the
# two small binaries and writes the pre-rendered site into dist/. One binary
# (bin/resume-server) serves the whole site; there is no bundler and no Node.
PYTHON ?= python3
VALIDATOR := $(PYTHON) tools/validate_content.py
GO ?= go
TEMPL ?= templ
# The runtime image is distroless/static (deploy/Dockerfile): no libc, no
# dynamic loader. Everything the server needs is pure Go (modernc.org/sqlite),
# so a static binary is buildable and is what the container can actually exec.
# Without this the build host's cgo links the nix glibc loader and the container
# fails with "exec /usr/bin/resume-server: no such file or directory".
CGO_ENABLED ?= 0
BIN := bin
DIST := dist
.PHONY: build dist generate dev test validate validate-self-test fmt vet clean help \
corpus corpus-sync corpus-rebuild corpus-audit corpus-verify corpus-demo corpus-test
help:
@echo "make build templ generate, compile bin/, no dist"
@echo "make dist build, then pre-render content into dist/"
@echo "make dev pre-render into dist/ and serve it on 127.0.0.1:8096"
@echo "make generate run templ generate (generated files are not committed)"
@echo "make test go test ./... plus the validator self-test"
@echo "make validate validate content/** front-matter against the frozen schema"
@echo "make fmt vet gofmt / go vet over cmd and internal"
@echo "make clean remove bin/ and dist/"
@echo ""
@echo "corpus (Phase 4, python3 -m corpus.cli):"
@echo "make corpus rebuild the corpus from content/ (drops the collection)"
@echo "make corpus-sync incremental update; only changed documents are re-embedded"
@echo "make corpus-audit freshness + drift audit (exit 1 on findings)"
@echo "make corpus-verify check the index against content/ and Qdrant"
@echo "make corpus-demo same query with and without an area filter"
@echo "make corpus-test corpus unit tests (no container, no network)"
build: generate
@mkdir -p $(BIN)
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -trimpath -buildvcs=false -o $(BIN)/resume-build ./cmd/build
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -trimpath -buildvcs=false -o $(BIN)/resume-server ./cmd/server
dist: build
@$(BIN)/resume-build --dist $(DIST)
dev: dist
@RESUME_DIST=$(DIST) $(BIN)/resume-server
generate:
@command -v $(TEMPL) >/dev/null || { echo "templ is not on PATH; run this inside 'nix develop'"; exit 1; }
@$(TEMPL) generate
test: generate validate-self-test
@$(GO) test ./...
validate:
@$(VALIDATOR)
validate-self-test:
@$(VALIDATOR) --self-test
fmt:
@gofmt -l -w cmd internal
vet:
@$(GO) vet ./...
# -- corpus (Phase 4, docs/phase-4-corpus.md) --------------------------------
#
# The corpus is data, not a build artefact: it lives in data/corpus/ (gitignored)
# and in Qdrant. RESUME_QDRANT_URL defaults to http://127.0.0.1:6333 and
# RESUME_EMBED_PROVIDER defaults to local-hashing; production sets both in the
# environment, never in this file.
CORPUS := $(PYTHON) -m corpus.cli
corpus: corpus-rebuild
corpus-rebuild:
@$(CORPUS) rebuild
corpus-sync:
@$(CORPUS) sync
corpus-audit:
@$(CORPUS) audit --fail-on-findings
corpus-verify:
@$(CORPUS) verify
corpus-demo:
@$(CORPUS) demo-filter --query "distributed audio endpoints" --area edge
corpus-test:
@$(PYTHON) -m unittest discover -s corpus/tests -t .
clean:
@rm -rf $(BIN) $(DIST)

View File

@@ -37,6 +37,27 @@ This folder **is** the git working copy of `sam/ai-resume`. One repo, one produc
Previously held an abandoned Vite/React scaffold. It was replaced by this build spec; the history is retained because it honestly records the false start, and it is referenced in the site's "alternatives considered" content.
## Build and run
The site is one Go binary serving pre-rendered HTML. There is no Node, no bundler and no
`node_modules`; `go.sum` is the only dependency lock.
```sh
nix develop # go, templ, sqlite3, make, curl
make validate # content gate: front-matter against the frozen schema
make test # go test ./... plus the validator self-test
make dist # templ generate → bin/ → pre-rendered dist/
make dev # build, then serve on http://127.0.0.1:8096
```
`bin/resume-build` reads `content/` and writes `dist/` (pages, stage fragments, the fragment
manifest, the SQLite FTS5 index, assets). `bin/resume-server` serves `dist/` and never reads
`content/`, never calls a model, and never opens a socket to another service — that is what
keeps acceptance test A1 true.
The two things that are not files are `/search` (FTS5 over page titles and body text) and
`/api/stage/stream` (the presentation panel driven by an event stream instead of a route).
## Ground rules
- **Do not touch other systems.** No stopping containers, no editing other projects, no `nixos-rebuild`, no Caddy changes except the single block specified in Phase 3 — and that one Sam applies.
@@ -48,8 +69,8 @@ Previously held an abandoned Vite/React scaffold. It was replaced by this build
## Current state
- [x] Repo prepared, scaffold retired, build spec written
- [ ] Phase 1 — content source of truth
- [ ] Phase 2 — static site
- [x] Phase 1 — content source of truth (40 documents across six areas, `make validate`)
- [x] Phase 2 — static site (`make dist`, 45 pages, 86 stage fragments, FTS5 search)
- [ ] Phase 3 — public deployment
- [ ] Phase 4 — corpus
- [ ] Phase 5 — AI overlay

58
cmd/build/main.go Normal file
View File

@@ -0,0 +1,58 @@
// Command build pre-renders the whole site into a directory.
//
// It reads content/ and writes dist/: every page as HTML, one file per stage
// fragment, the fragment manifest, the SQLite FTS5 index, the assets and the
// static files. No network call is made, and the output is what the server
// serves — Mode A never renders a page per request.
//
// Configuration is from the environment, so the same binary is used by
// `make build`, by CI and by the container image:
//
// RESUME_ROOT repository root (default ".")
// RESUME_CONTENT content directory (default $RESUME_ROOT/content)
// RESUME_DIST output directory (default $RESUME_ROOT/dist)
package main
import (
"flag"
"fmt"
"os"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/site"
)
func main() {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "usage: build [--root DIR] [--content DIR] [--dist DIR]\n")
fmt.Fprintf(flag.CommandLine.Output(), "environment: RESUME_ROOT, RESUME_CONTENT, RESUME_DIST\n")
}
root := flag.String("root", env("RESUME_ROOT", "."), "repository root")
contentDir := flag.String("content", env("RESUME_CONTENT", ""), "content directory")
distDir := flag.String("dist", env("RESUME_DIST", ""), "output directory")
flag.Parse()
cfg := site.Config{Root: *root, ContentDir: *contentDir, OutDir: *distDir}
s, err := site.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "build: %v\n", err)
os.Exit(1)
}
if err := s.Build(); err != nil {
fmt.Fprintf(os.Stderr, "build: %v\n", err)
os.Exit(1)
}
info, err := site.LoadBuildInfo(s.Config.OutDir)
if err != nil {
fmt.Fprintf(os.Stderr, "build: %v\n", err)
os.Exit(1)
}
fmt.Printf("built %d pages, %d stage fragments, %d indexed documents into %s\n",
info.Pages, info.Fragments, info.Indexed, s.Config.OutDir)
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

155
cmd/server/main.go Normal file
View File

@@ -0,0 +1,155 @@
// Command server serves the site the build wrote.
//
// It reads a directory produced by cmd/build and serves it on loopback. It does
// not read content/, does not need Qdrant, OmniRoute or any other service, and
// makes no outbound connection, so every page it serves works with the AI stack
// stopped (acceptance test A1).
//
// Configuration comes from the environment so the same binary works under
// `make dev`, systemd and compose:
//
// RESUME_ADDR listen address (default 127.0.0.1:8096)
// RESUME_DIST build directory (default "dist")
// RESUME_STREAM_DELAY pacing for the streamed panel, e.g. 350ms
//
// Flags with the same names override the environment for local work.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/server"
)
func main() {
addr := flag.String("addr", env("RESUME_ADDR", server.DefaultAddr), "listen address")
dist := flag.String("dist", env("RESUME_DIST", server.DefaultDist), "build directory")
streamDelay := flag.Duration("stream-delay", envDuration("RESUME_STREAM_DELAY", server.DefaultStreamDelay), "delay between streamed panel frames")
healthCheck := flag.Bool("health-check", false, "ask a running server for /healthz and exit 0 only if static is ready")
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "usage: server [--addr HOST:PORT] [--dist DIR] [--stream-delay DURATION] [--health-check]\n")
fmt.Fprintf(flag.CommandLine.Output(), "environment: RESUME_ADDR, RESUME_DIST, RESUME_STREAM_DELAY\n")
}
flag.Parse()
// The container image has no shell and no curl, so the health probe is this
// binary talking to itself (deploy/compose.yml healthcheck).
if *healthCheck {
if err := probe(*addr); err != nil {
fmt.Fprintf(os.Stderr, "health check failed: %v\n", err)
os.Exit(1)
}
fmt.Println("static ready")
return
}
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
site, err := server.New(server.Config{
Addr: *addr,
DistDir: *dist,
StreamDelay: *streamDelay,
Logger: log,
})
if err != nil {
log.Error("start failed", "error", err)
os.Exit(1)
}
defer site.Close()
// No WriteTimeout on purpose: the stage stream keeps the response open for
// as long as it is producing frames. Reads are bounded.
srv := &http.Server{
Addr: *addr,
Handler: site.Handler(),
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 64 << 10,
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
log.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Warn("shutdown", "error", err)
}
}()
log.Info("listening", "addr", *addr, "dist", *dist)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Error("serve failed", "error", err)
os.Exit(1)
}
}
// probe asks a running server whether the static site is ready. Only the static
// field decides the exit code: Mode A must report ready with every AI service
// stopped (acceptance test A1).
func probe(addr string) error {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return err
}
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
client := &http.Client{Timeout: 3 * time.Second}
res, err := client.Get("http://" + net.JoinHostPort(host, port) + "/healthz")
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return fmt.Errorf("/healthz returned %s", res.Status)
}
var body struct {
Static struct {
Ready bool `json:"ready"`
} `json:"static"`
}
if err := json.NewDecoder(io.LimitReader(res.Body, 64<<10)).Decode(&body); err != nil {
return fmt.Errorf("decode /healthz: %w", err)
}
if !body.Static.Ready {
return errors.New("static is not ready")
}
return nil
}
func env(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}
func envDuration(key string, fallback time.Duration) time.Duration {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return fallback
}
d, err := time.ParseDuration(v)
if err != nil {
fmt.Fprintf(os.Stderr, "%s=%q is not a duration: %v\n", key, v, err)
os.Exit(2)
}
return d
}

View File

@@ -0,0 +1,75 @@
---
id: about-narrative
area: about
level: 2
title: "The narrative — including the years that need explaining"
claim: "A self-taught career across six technology eras — interrupted on purpose by a non-IT working life and five years overseas — with 2020 to 2026 spent raising two children while building a business."
confidence: running
period: "1999–"
tech: [NixOS, Neovim, Python, JavaScript, React, Go, HTMX]
metrics: [{label: "years shipping software", value: 25}, {label: "years spent overseas", value: 5}, {label: "non-IT roles worked", value: 6}]
artefacts: [{type: doc, label: "Non-IT working history", url: "content/about/l3/about-non-it-work.md"}, {type: doc, label: "Education", url: "content/about/l3/about-education.md"}, {type: doc, label: "Interests — one honest line each", url: "content/about/l3/about-interests.md"}]
public: true
last_verified: 2026-10-26
tags: [about, narrative, 2020-2026]
---
# The narrative — including the years that need explaining
**Capability statement.** Someone who learned by doing, in public, on deadlines, and who has kept learning after the deadlines stopped being someone else's. The range is real and the gaps are stated.
## The beginning
Programming started as a childhood interest in the 1980s, self-taught alongside art, music, philosophy and sport. The career proper started in the late 1990s building websites for an Australian car marketplace. When interactive front ends arrived — a creative layer that needed a technical spine — there was no course to take, so it was learned directly and immediately put into commercial work. That combination, creative front end with engineering underneath, has been the shape of the work ever since.
## The agency years
Freelance in Melbourne, then full-time roles in the United Kingdom during the emergence of online gaming and interactive advertising, then back to freelance where the work was with the largest agencies and their clients — banks, broadcasters, drinks brands, publishers, a global energy company, a design software vendor. Most of it was delivered under deadlines that could not move, and a meaningful share of it was rescue work: rewriting someone else's stalled project and shipping it on their date.
## The other working life
Not all of it was software, and that is worth stating rather than hiding. Six roles outside technology — a ski chalet kitchen in the French Alps, wine service, guided horse tours in the Victorian high country, building a stable complex, state government administration, and thoroughbred stable work. They are the reason the engineering reads as practical: a kitchen at service and a production incident have the same shape.
## The education
A fine arts degree with philosophy, a master's in international relations and contemporary political theory, and study in music composition and economics. None of it is computer science, and all of it is used: systems thinking, argument, aesthetics, and the habit of asking what a thing is actually for.
## 2020 to 2026 — stated plainly
This period is on the page because a résumé that hides it invites the wrong question. It was spent as a full-time parent to two children while building in parallel:
- A digital signage business taken from idea to customers — the product, the deployments, the support.
- A site builder and CMS that carried the marketing for it.
- A lost-property tag system, deployed.
- A homelab that became the platform for everything else: declarative configuration, containers, backups, networking, public ingress.
- An AI estate — pipelines, routing, retrieval, voice, guardrails — built and operated on the same hardware.
- A deliberate move from PHP and front-end scripting into Go, systems work and platform engineering, and from hand-installed servers into reproducible configuration.
The honest framing is in the evidence notes and is repeated here: *full-time parent, building in parallel*. The output of those years is on the rest of this site and can be inspected.
## What next
The same three areas at a larger scale: AI and agentic systems, the platform engineering underneath them, and the constrained devices at the edge. Melbourne based and flexible about location and format — remote, hybrid, office, or travel.
## Contact policy
This site publishes no address, no phone number and no family details. Contact is by email, with the address supplied directly rather than scraped from a page. If a phone number belongs on the site, that is Sam's decision to make and it is not made here.
## Trade-offs and limits
- **No computer science degree.** The trade is a longer, broader practical education; the pages on this site are the argument, not the certificate.
- **A period of self-employment is described as such.** It is not dressed up as a sabbatical or as consulting.
- **Interests are one honest sentence each.** The previous version of this site invented venues and results. That has been deleted rather than corrected in place.
## Drill-downs
- [Non-IT working history](l3/about-non-it-work.md) — level 3
- [Education](l3/about-education.md) — level 3
- [Interests — one honest line each](l3/about-interests.md) — level 3
- [The AI estate](../ai/ai-systems.md) — level 2
- [The platform estate](../platform/platform-estate.md) — level 2
- [The edge fleet](../edge/edge-fleet.md) — level 2
## Period and confidence
`period: 1999–`. Confidence is `running`: this is the current self-description, verified against the evidence notes on 2026-10-26.

View File

@@ -0,0 +1,25 @@
---
id: about-overview
area: about
level: 1
title: "About"
claim: "Twenty-five years of shipping software — built through self-teaching · a non-IT working life · and five years overseas — now aimed at AI systems · the platforms under them and the devices at the edge."
confidence: running
period: "1999–"
tech: [NixOS, Neovim, Python, JavaScript, React, Go, HTMX]
metrics: [{label: "years shipping software", value: 25}, {label: "years spent overseas", value: 5}, {label: "non-IT roles worked", value: 6}]
artefacts: [{type: doc, label: "Non-IT working history", url: "content/about/l3/about-non-it-work.md"}, {type: doc, label: "Education", url: "content/about/l3/about-education.md"}, {type: doc, label: "Interests — one honest line each", url: "content/about/l3/about-interests.md"}]
public: true
last_verified: 2026-10-26
tags: [about, narrative, background]
---
# About
**Claim.** A career built by self-teaching — from a childhood in the 1980s with a borrowed language reference, through fifteen years of agency delivery, a working life outside technology, and five years overseas — now concentrated on AI systems, the platforms they run on, and the hardware at the edge.
**Status.** Current. Melbourne based, and flexible about how the work is done — remote, hybrid, office, or travel.
**What this page is for.** The other five areas prove what can be built. This one explains where the judgement came from: a non-technical working life, a fine-arts and philosophy education, and a period spent building a business while raising two children.
**Start here.** [The narrative](about-narrative.md) — including an honest account of 2020 to 2026.

View File

@@ -0,0 +1,37 @@
---
id: about-education
area: about
level: 3
title: "Artefact — Education"
claim: "A fine arts degree with philosophy · a master's in international relations and contemporary political theory · and study in music composition and economics."
confidence: historic
period: "1997–"
tech: []
metrics: [{label: "tertiary qualifications completed", value: 2}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [about, education, artefact]
---
# Artefact — Education
**Status: stub.** Formal presentation lands in Phase 7. No certificates, transcripts or student identifiers are published.
## The record
- **Master of International Relations and Contemporary Political Theory** — University of Westminster, Centre for the Study of Democracy.
- **Bachelor of Visual Communication and Fine Arts** — RMIT (Melbourne) and the University of South Australia, 1997–1999.
- **Music composition and economics** — studied at La Trobe University alongside the composition work described under interests.
## What it proves
Two things the technical record cannot: formal training in argument and political economy, and formal training in visual judgement. Domain-switching is a habit rather than a claim — the degrees are in different fields from each other and from the career.
## Why no computer science degree
Because there is not one, and saying so is cheaper than having it discovered. The self-taught path is documented across this site: the History pages show the eras, the Platform and Edge pages show what came of them.
## Coverage limit
Qualification names, awarding institutions and the years the résumé states are published. Grades, student numbers and the master's completion year are not in the sources and are therefore not stated.

View File

@@ -0,0 +1,72 @@
---
id: about-interests
area: about
level: 3
title: "Artefact — Interests · one honest line each"
claim: "Fourteen pursuits with one true sentence each — and the invented venues · results and trips from the previous version of this site deleted rather than restated."
confidence: running
period: "1980–"
tech: []
metrics: [{label: "pursuits listed", value: 15}, {label: "bands played in", value: 2}, {label: "bee hives kept", value: 2}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [about, interests, artefact]
---
# Artefact — Interests · one honest line each
**Status: stub for the visual treatment.**
## Music
Blues and jazz piano and blues harp; played in two bands. Studied music composition at university and kept the piano as the thing that resets the week.
## Fine art
A fine arts degree and a continuing practice. Drawing and painting taught patience with iteration, which is most of debugging.
## Philosophy
A degree's worth of it, and it is where the habit of asking what a thing is actually for came from.
## Skiing
Italy, France, Japan and Australia. Six months based in Meribel in the French Alps as a chalet chef, including the Vallée Blanche glacier run.
## Camping
Hiking, camping and canoeing, mostly in Victoria. Most recent: a two-day hike through Wilsons Promontory with a group of teenagers in tow.
## Canoeing
The Yarra, the Otways and the Murray.
## Horse riding
Grew up riding in the Otways; broke horses from track work back to dressage; guided tours in the Victorian high country; competed in dressage for two years.
## Surfing
Grew up between Lorne and the Otways, on waves and horses.
## Hiking
The walking version of the camping answer: long days, small pack, no phone signal.
## Global politics
The master's was in it. Reading it is a habit, not a credential to display.
## Reading
History, philosophy, politics, economics — the same four subjects as the degrees, which is either consistency or a rut.
## Cooking
Worked in many restaurants and as chef in a five-star chalet in Meribel, France.
## Gardening
Permaculture and a wood-chip, low-input approach to self-sufficiency, with sensors and automation doing the boring parts.
## Food production
Bees, salami and cheese. Two hives, and salami made annually from four pigs with Italian friends in Benalla.
## Italy and travel
Five years spent overseas, travelling extensively, with Italy as the standing preference.
## What was deleted, and why
The previous site claimed venues, competition results, swell sizes, distances and named restaurants. None of it could be traced. It has been cut rather than corrected: a single true sentence is worth more than ten specific ones that cannot be substantiated, and an interviewer who asks a follow-up question is entitled to an answer that survives it.
## Coverage limit
Everything above traces to Sam's own notes. Anything not in those notes is absent, not paraphrased.

View File

@@ -0,0 +1,42 @@
---
id: about-non-it-work
area: about
level: 3
title: "Artefact — Non-IT working history"
claim: "Six roles outside technology — a chalet kitchen in the French Alps · wine service · guided horse tours · a stable build · government administration · thoroughbred stable work."
confidence: historic
period: "1990–2010"
tech: []
metrics: [{label: "non-IT roles worked", value: 6}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [about, history, work, artefact]
---
# Artefact — Non-IT working history
**Status: stub for the visual treatment.** The roles are final; the presentation lands in Phase 7.
## The roles
| Role | Where | What it involved |
|---|---|---|
| Chalet chef | Merribel, France | Meals for chalet guests, kitchen operations, menu planning, dietary requirements, in a high-pressure alpine season |
| Wine waiter | South Australia and Victoria | Premium wine regions, varietals, food pairing |
| Horseback tour guide | Victorian high country | Guided tours, rider safety, horse welfare, trail navigation |
| Stable construction | Mount Beauty, Victoria | A 24-bay stable build — project management, budgeting, hands-on construction |
| Civil servant | Victorian State Government, Melbourne | Administrative and operational support to state-level programs |
| Horse trainer and stable hand | Sunbury, Victoria | Care and training of thoroughbreds, exercise, grooming, race-day preparation |
## What it proves
That the engineering sits on a wider working life. Kitchen service and production incidents share a shape; guiding strangers on horses in the high country is risk management with consequences; a stable build is a budget and a schedule held by hand. These are the reasons the technical pages read as practical rather than academic.
## Why this is on a technical site
Because leaving it out would make the career look narrower than it is, and because the years it occupies are otherwise unexplained.
## Coverage limit
Descriptions are limited to what the résumé and the evidence notes corroborate. Roles, employers' towns and the type of work are stated; no employer is named beyond the region, and no claim is made about scale, revenue or headcount that the sources do not carry.

25
content/ai/ai-overview.md Normal file
View File

@@ -0,0 +1,25 @@
---
id: ai-overview
area: ai
level: 1
title: "AI & Agentic Systems"
claim: "Builds and operates sixteen or more AI pipelines — agent harnesses · model routing and cost control · retrieval over a tagged corpus · and a voice front-end that runs a house."
confidence: running
period: "2020–"
tech: [LangGraph, Prefect, n8n, Qdrant, OmniRoute, Whisper, Piper, MiniMax]
metrics: [{label: "pipelines maintained", value: 16}, {label: "model providers behind one gateway", value: 6}, {label: "audio endpoints for spoken output", value: 8}]
artefacts: [{type: doc, label: "House AI hub — service topology", url: "content/ai/l3/ai-hub-architecture.md"}, {type: doc, label: "Model routing and cost control", url: "content/ai/l3/ai-omniroute-routing.md"}, {type: doc, label: "Voice pipeline — speech in and speech out", url: "content/ai/l3/ai-voice-pipeline.md"}]
public: true
last_verified: 2026-10-26
tags: [ai, agents, routing, retrieval, voice]
---
# AI & Agentic Systems
**Claim.** Sixteen or more AI pipelines are maintained in production — ten or more on n8n, Prefect and LangGraph, six on Paperclip — with one gateway in front of six model providers, retrieval over a metadata-tagged corpus, and a voice front-end that controls the house.
**Status.** Running. Every figure on this page traces to the evidence notes; the verification date is 2026-10-26.
**Why it reads as engineering rather than enthusiasm.** The pipelines run against real obligations — school portals, calendars, email, household audio — and they are operated: routed for cost, guarded before they act, and observed while they run.
**Start here.** [The capability page](ai-systems.md) explains what is actually run and what the trade-offs were. The three chips above resolve to artefacts below it.

70
content/ai/ai-systems.md Normal file
View File

@@ -0,0 +1,70 @@
---
id: ai-systems
area: ai
level: 2
title: "Agent systems that are operated rather than demonstrated"
claim: "Maintains sixteen or more production AI pipelines — agent harnesses · prompt-analysis routing across six providers · retrieval filtered by metadata before it searches · and a guarded voice front-end."
confidence: running
period: "2020–"
tech: [LangGraph, Prefect, n8n, Paperclip, Qdrant, OmniRoute, Whisper, Piper, MiniMax, TUIOS]
metrics: [{label: "pipelines maintained", value: 16}, {label: "model providers behind one gateway", value: 6}, {label: "audio endpoints for spoken output", value: 8}, {label: "hosts under standardised agent operations", value: 4}]
artefacts: [{type: doc, label: "House AI hub — service topology", url: "content/ai/l3/ai-hub-architecture.md"}, {type: doc, label: "Model routing and cost control", url: "content/ai/l3/ai-omniroute-routing.md"}, {type: doc, label: "Paperclip and n8n pipelines in production", url: "content/ai/l3/ai-paperclip-pipelines.md"}, {type: doc, label: "Retrieval corpus and query graph", url: "content/ai/l3/ai-rag-corpus.md"}, {type: doc, label: "Voice pipeline — speech in and speech out", url: "content/ai/l3/ai-voice-pipeline.md"}, {type: doc, label: "Guardrails — hooks in the model path", url: "content/ai/l3/ai-guardrails.md"}, {type: doc, label: "Memory in two layers", url: "content/ai/l3/ai-memory-layers.md"}]
public: true
last_verified: 2026-10-26
tags: [ai, agents, routing, retrieval, voice, guardrails]
---
# Agent systems that are operated rather than demonstrated
**Capability statement.** This is a working estate, not a prototype: more than sixteen pipelines in production, six providers behind a single gateway, retrieval that filters on metadata before it searches, and a voice front-end used daily by people who did not build it.
## What is actually run
**Routing.** Every model call goes through one gateway — OmniRoute. Six providers sit behind it: DeepSeek, MiniMax, OpenRouter, OpenCode-Go, Gemini and OpenAI. Requests are classified before they are sent, so the cheapest model that can do the job gets the job. No project in the estate calls a provider directly; that is a rule, not a preference.
**Pipelines.** Ten or more are maintained on n8n, Prefect and LangGraph — email scanners, school-portal scanners, calendar scanners, project agents — plus six Paperclip pipelines that run work through a bench of narrow specialist agents behind human approval gates. The estate is registered in a dashboard built to keep track of it.
**Harnesses.** JEV is a custom harness with its own routing, tool set and memory rather than a wrapper around a vendor SDK. Work is executed as sequences of narrow agents instead of one large prompt; capability is loaded per task through a skill-selection step, so the agent gets what this job needs and not an enormous system prompt.
**Memory.** Two deliberate layers. Engram keeps what should persist — curated summaries, decisions, prompts and session handoffs in local SQLite, integrated with JEV for fast compaction. Headroom keeps what should not persist out of the window, stripping and compressing prompt context, tool output and logs before they reach the model. Storage and token control are separate problems and are solved separately.
**Retrieval.** ChromaDB first, then replaced with Qdrant for payload-filtered retrieval and a smaller footprint. LangGraph query graphs on top; Prefect and n8n for ingestion. The corpus is metadata-tagged at ingest, which means retrieval filters before it searches — the difference between a device answer and a work-history answer is a filter, not a better prompt.
**Voice.** Local Whisper for speech to text with a fallback, Piper for text to speech, MiniMax for a cloned voice with emotion and non-verbal cues. A custom wake word on an ESP32 with LED confirmation. Voice is a system front-end: it controls lighting, music, calendars, shopping, email, SMS and alerts, and it answers through eight Snapcast endpoints with music ducked underneath.
**Guardrails.** Custom hooks live in the model path and block injection and constrain tool use, built because the house voice channel is shared and unauthenticated and there was no vendor control to borrow. Consequential actions sit behind approval gates, and no model sits in the path of a fast or dangerous command. The public site applies the same reasoning from the other end: the assistant it exposes has no tools at all, so the attack surface is a conversation rather than a shell.
**Agent operations.** Agent sessions are standardised on TUIOS across four hosts — sessions survive a disconnect, agents report their own state, and approvals land in one inbox. TUIOS is a third-party tool: the contribution is operations and packaging, not authorship. It is pinned at 0.10.1 in a hand-written Nix derivation because nixpkgs lagged at 0.7.0, and its daemon is declared as a systemd user service so the fleet is reproducible rather than hand-installed.
## Evidence
| Claim | Evidence | Artefact |
|---|---|---|
| Sixteen or more pipelines in production | Ten or more on n8n, Prefect and LangGraph plus six on Paperclip | [Paperclip and n8n pipelines](l3/ai-paperclip-pipelines.md) |
| Six providers behind one gateway | DeepSeek, MiniMax, OpenRouter, OpenCode-Go, Gemini, OpenAI | [Model routing](l3/ai-omniroute-routing.md) |
| Retrieval filters before it searches | Corpus tagged at ingest; Qdrant payload filters; LangGraph query nodes | [Corpus and query graph](l3/ai-rag-corpus.md) |
| Spoken output reaches eight endpoints | Eight Snapcast clients with music ducking | [Voice pipeline](l3/ai-voice-pipeline.md) |
| Guardrails are code in the path | Injection-blocking hooks and constrained tool use | [Guardrails](l3/ai-guardrails.md) |
| Memory is two layers by design | Engram for persistence, Headroom for context control | [Memory layers](l3/ai-memory-layers.md) |
| Agent operations are standardised | TUIOS across four hosts with one approval inbox | [House AI hub](l3/ai-hub-architecture.md) |
## Trade-offs and limits
- **Local inference was measured, then demoted.** The estate started local on Ollama. Hardware constraints made local inference both slower and more expensive than partitioned remote calls, so heavy flows moved remote while sensitive and low-latency work stayed local. That is a documented comparison, not a taste in hardware.
- **The retired stack is in the record.** Airflow DAGs were abandoned for RAM, ChromaDB was replaced by Qdrant, and a visual-builder layer was dropped. Each replacement is stated rather than hidden, because the reasoning is the transferable part.
- **Not everything is automated.** Parts of the house are automated end to end and parts still need a person to press the button. The estate is described as it is run, not as it was imagined.
- **Cost is bounded by routing, not by hope.** Caps and a refusal path exist so a runaway session cannot become a bill; the routing layer keeps the median request on a cheap model.
## Drill-downs
- [House AI hub — service topology](l3/ai-hub-architecture.md) — level 3
- [Model routing and cost control](l3/ai-omniroute-routing.md) — level 3
- [Paperclip and n8n pipelines in production](l3/ai-paperclip-pipelines.md) — level 3
- [Retrieval corpus and query graph](l3/ai-rag-corpus.md) — level 3
- [Voice pipeline — speech in and speech out](l3/ai-voice-pipeline.md) — level 3
- [Guardrails — hooks in the model path](l3/ai-guardrails.md) — level 3
- [Memory in two layers](l3/ai-memory-layers.md) — level 3
## Period and confidence
`period: 2020–`. The start is anchored on the homelab year in the evidence notes, because the AI estate and the homelab have the same origin. Confidence is `running`: this is the current arrangement, verified 2026-10-26 against the evidence notes.

View File

@@ -0,0 +1,31 @@
---
id: ai-guardrails
area: ai
level: 3
title: "Artefact — Guardrails · hooks in the model path"
claim: "Injection blocking and constrained tool use are code in the model path — built because the house voice channel is shared and unauthenticated."
confidence: running
period: "2020–"
tech: [OmniRoute, Python]
metrics: [{label: "tools exposed by the public assistant", value: 0}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [ai, guardrails, security, artefact]
---
# Artefact — Guardrails · hooks in the model path
**Status: stub.** A redacted injection-test result lands in Phase 6, when the guardrail library is wired into this site's own assistant.
## What the artefact is
The hooks that run before a request reaches a provider and before a response reaches an actuator: injection patterns are blocked, tool use is constrained to a declared set, and consequential actions stop at an approval gate. Behind them, one rule that outranks every prompt — no model sits in the path of a fast or dangerous command.
## What it proves
Safety enforced by mechanism. A prompt instruction is a suggestion; a hook is a control.
## When it is delivered
The test result in Phase 6, using the same library the house runs. The policy that the public assistant has no tools at all is a Phase 5 and 6 decision and is not negotiable at runtime.

View File

@@ -0,0 +1,31 @@
---
id: ai-hub-architecture
area: ai
level: 3
title: "Artefact — House AI hub service topology"
claim: "One hub fronts the AI estate — intake · routing · pipelines · retrieval · voice output · and a single set of network tools namespaced per household member."
confidence: running
period: "2020–"
tech: [n8n, Prefect, LangGraph, OmniRoute, Qdrant, MQTT, Snapcast]
metrics: [{label: "provider gateway entry points", value: 1}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [ai, architecture, artefact]
---
# Artefact — House AI hub service topology
**Status: stub.** The diagram and the generated topology dump land in Phase 7 (polish). This stub exists so the level 2 page has no dangling reference.
## What the artefact is
A one-page diagram of the hub: request intake (voice · chat · scheduled job), the gateway hop, the pipeline runners, the retrieval path, and the output paths (house speakers, phone alerts, dashboards). Every box is a service that runs; nothing on the diagram is aspirational.
## What it proves
That the estate is a designed system with a single gateway and a single set of namespaced tools — not a collection of scripts that happen to share a machine.
## When it is delivered
Phase 7. The source facts are in the evidence notes (sections 2 and 4). Capture must be from the live estate, so no diagram is drawn from memory in Phase 1.

View File

@@ -0,0 +1,31 @@
---
id: ai-memory-layers
area: ai
level: 3
title: "Artefact — Memory in two layers · Engram and Headroom"
claim: "Memory is split by purpose — Engram stores what should persist and Headroom keeps what should not persist out of the context window."
confidence: running
period: "2020–"
tech: [Engram, Headroom, SQLite, JEV]
metrics: [{label: "memory layers", value: 2}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [ai, memory, context, artefact]
---
# Artefact — Memory in two layers · Engram and Headroom
**Status: stub.** A redacted session handoff and a before-and-after context measurement land in Phase 7.
## What the artefact is
Engram is local-first storage for curated summaries, decisions, prompts and session handoffs in SQLite, so a conversation can be resumed in any project; it is integrated with JEV for fast compaction. Headroom is token control: it strips, cleanses and analyses code and logs before they reach the model, compressing rather than truncating.
## What it proves
Two distinct problems recognised as two — persistence and window economy — instead of one tool asked to do both.
## When it is delivered
Phase 7. Both tools are part of Sam's own workflow; they are deliberately not part of the public assistant, which is stateless by decision (see `reference/decisions.md` D11).

View File

@@ -0,0 +1,31 @@
---
id: ai-omniroute-routing
area: ai
level: 3
title: "Artefact — Model routing and cost control"
claim: "Every model call is classified first and sent to the cheapest capable provider — six providers behind one gateway with no direct SDK calls anywhere in the estate."
confidence: running
period: "2020–"
tech: [OmniRoute, JEV, DeepSeek, MiniMax, OpenRouter, Gemini, OpenAI]
metrics: [{label: "providers in rotation", value: 6}, {label: "gateways", value: 1}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [ai, routing, cost, artefact]
---
# Artefact — Model routing and cost control
**Status: stub.** The routing decision path and a redacted cost table land in Phase 7. No keys or account identifiers are ever published.
## What the artefact is
The decision path: request arrives, a prompt-analysis step classifies it, the gateway selects a provider and model, and the response returns with its cost recorded. Plus the comparison that demoted local inference for heavy flows — local Ollama measured slower and more expensive under the available hardware than partitioned remote calls.
## What it proves
Cost control implemented as routing and measurement rather than as a monthly promise, and a single chokepoint where provider access can be audited.
## When it is delivered
Phase 7. The evidence for the provider list and the local-versus-remote decision is in the evidence notes, section 2 and section 4.

View File

@@ -0,0 +1,31 @@
---
id: ai-paperclip-pipelines
area: ai
level: 3
title: "Artefact — Paperclip and n8n pipelines in production"
claim: "Sixteen or more pipelines run real work — scanners · schedulers · project agents — with specialist agents dispatched by task and human approval gates on anything consequential."
confidence: running
period: "2020–"
tech: [Paperclip, n8n, Prefect, LangGraph]
metrics: [{label: "pipelines on n8n and Prefect and LangGraph", value: 10}, {label: "Paperclip pipelines", value: 6}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [ai, pipelines, agents, artefact]
---
# Artefact — Paperclip and n8n pipelines in production
**Status: stub.** A redacted run log of one pipeline lands in Phase 7, alongside the pipeline inventory.
## What the artefact is
One pipeline shown end to end: trigger, steps, the agent or model each step uses, the approval gate, and the run log with identifiers removed. Alongside it, the inventory of what the sixteen or more pipelines do — email scanners, school-portal scanners, calendar scanners, project oversight, and the household automations.
## What it proves
Volume with operations behind it: these are scheduled, logged and monitored, not run once for a demonstration.
## When it is delivered
Phase 7. Counts come from the evidence notes, section 3.

View File

@@ -0,0 +1,31 @@
---
id: ai-rag-corpus
area: ai
level: 3
title: "Artefact — Retrieval corpus and query graph"
claim: "Retrieval is filtered by metadata before it searches — a tagged corpus · a four-node LangGraph query graph · and a deliberate replacement of ChromaDB with Qdrant."
confidence: running
period: "2020–"
tech: [Qdrant, LangGraph, Prefect, n8n, ChromaDB]
metrics: [{label: "retrieval stores used over time", value: 2}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [ai, retrieval, rag, artefact]
---
# Artefact — Retrieval corpus and query graph
**Status: stub.** The query graph diagram and an ingest run log land later; this site's own corpus build is Phase 4.
## What the artefact is
The ingest and query path: documents are chunked and tagged with metadata at ingest, stored in Qdrant, and filtered on that metadata before a similarity search runs. The LangGraph graph has four nodes; the fourth decides whether the answer can be given from what was retrieved.
## What it proves
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.
## 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.

View File

@@ -0,0 +1,31 @@
---
id: ai-voice-pipeline
area: ai
level: 3
title: "Artefact — Voice pipeline · speech in and speech out"
claim: "Speech in on a custom wake word · local transcription · a cloned voice out · and music ducked under every announcement across eight endpoints."
confidence: running
period: "2020–"
tech: [Whisper, Piper, MiniMax, ESP32, Snapcast, Mopidy, Home Assistant]
metrics: [{label: "audio endpoints", value: 8}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [ai, voice, audio, artefact]
---
# Artefact — Voice pipeline · speech in and speech out
**Status: stub.** A short recording of music ducking under a spoken announcement lands in the optional voice phase. Text describes the chain until then.
## What the artefact is
The chain: an ESP32 device with a microphone detects the wake word and confirms with an LED, audio is transcribed locally with Whisper, the request goes through the gateway, and the reply is spoken through Piper or the MiniMax cloned voice over all eight Snapcast endpoints, with music lowered for the announcement and restored afterwards.
## What it proves
Voice treated as a system front-end with latency and reliability requirements — not a party trick bolted onto a chatbot.
## When it is delivered
Recording in the optional voice phase (Phase 7). The endpoint count and the ducking behaviour are from the evidence notes, sections 1 and 2.

0
content/assets/.gitkeep Normal file
View File

View File

@@ -1,68 +0,0 @@
# Phase 7.4 — asset weight budget
Measured 2026-10-26 on `.13` with `stat -c%s` and `gzip -9`. Every number below
is a measurement of a file that is in this repository, not an estimate.
## What is in the assets directory
| Group | Files | Raw | Gzip (`-9`) |
|---|---|---|---|
| Diagrams (`diagrams/*.svg`) | 8 | 51,729 B | 8,943 B |
| Code exhibits (`code/*.as`, `.js`, `.php`, `.ts`, `.hpp`, `.nix`) | 6 | 8,161 B | 2,989 B |
| Client wall data (`logos/clients.yaml`) | 1 | 3,912 B | 1,288 B |
| **Served payload total** | **15** | **63,802 B** | **13,220 B** |
| Manifests and READMEs (build-time only, never served) | 5 | 15,955 B | — |
## Per-asset
| File | Raw | Gzip |
|---|---|---|
| `diagrams/01-homelab-topology.svg` | 7,079 | 2,169 |
| `diagrams/02-agent-architecture.svg` | 6,027 | 1,971 |
| `diagrams/03-rag-pipeline.svg` | 7,010 | 2,043 |
| `diagrams/04-edge-architecture.svg` | 6,896 | 2,163 |
| `diagrams/05-distributed-audio.svg` | 6,833 | 1,997 |
| `diagrams/06-media-pipeline.svg` | 5,694 | 1,718 |
| `diagrams/07-cost-resource-engineering.svg` | 6,151 | 2,023 |
| `diagrams/08-era-timeline.svg` | 6,039 | 1,930 |
| `code/01-actionscript-class.as` | 958 | 495 |
| `code/02-js-easing-core.js` | 1,848 | 584 |
| `code/03-php-encode-op.php` | 2,295 | 765 |
| `code/04-typescript-service-layer.ts` | 1,078 | 482 |
| `code/05-embedded-cpp-interface.hpp` | 788 | 419 |
| `code/06-nix-derivation.nix` | 1,194 | 724 |
Rasters: **zero.** There are no PNG, JPEG, WebP or font files in this phase.
That is a design outcome, not an oversight — every diagram is text-based SVG and
every code exhibit is text, so the "optimise every image" requirement has no
image to optimise yet. Phase 7.4 screenshots are deferred (see below).
## The budget
| Rule | Limit | Worst case today |
|---|---|---|
| Any single diagram | ≤ 7.5 KB raw / ≤ 2.5 KB gzip | 7,079 B / 2,169 B |
| Any single code exhibit | ≤ 3 KB raw / ≤ 1 KB gzip | 2,295 B / 765 B |
| Diagrams on one page | ≤ 2 | 2 (14.1 KB raw) |
| Asset payload per page, gzipped | ≤ 12 KB | 4.3 KB (two diagrams + one exhibit) |
| Web fonts | 0 | 0 — system font stack only |
| JS required by any asset | 0 | 0 — SVG carries its own palette |
Worst realistic page: an L2 page with two diagrams and one code exhibit is
14,089 B raw, about **4.3 KB over the wire** after gzip and before the HTML, CSS
and template overhead. The whole 15-file asset set is 13.2 KB gzipped, so even a
page that loaded every one of them would stay inside a single small image's
budget.
## What is not yet measured, and why
| Item | Status | Why |
|---|---|---|
| Full page weight (HTML + CSS + assets) | **not measured** | No page exists yet: Phase 2 owns the templates |
| Lighthouse ≥ 95 / 95 | **not measured** | Needs a rendered page (Phase 2) served behind the edge (Phase 3). Nothing in this phase can stand in for it |
| Screenshots and recordings (7.4) | **deferred** | The agent console is Phase 5 and the TUIOS fleet session has to be real work, not staged. Recording a console that does not exist would be a fabrication |
| Home Assistant / Snapcast stills | **deferred** | Same reason: needs a live system and a judgement about whether a still adds what diagram 4 and diagram 5 already show |
These are recorded as open rather than asserted. DoD items 4 and 5 cannot be
closed until Phase 2 and Phase 3 produce a page to measure, and the measurement
must be taken by whoever holds that page.

View File

@@ -1,73 +0,0 @@
# content/assets — authored exhibits
Everything the site shows that is not Markdown prose. Authored in Phase 7; wired
into templates by Phase 2's builder.
```
content/assets/
├── BUDGET.md measured weights for every asset, and the budget
├── VERIFY.md what was checked, with the output pasted in
├── diagrams/ 8 SVGs, light and dark in one file each
│ ├── README.md the visual language and the presentation contract
│ └── manifest.yaml id, area, level, title, alt text per diagram
├── code/ 6 verbatim excerpts, 212 lines, 5 eras
│ ├── README.md the rules these follow
│ └── manifest.yaml era, language, shows, rationale, honest note
└── logos/
└── clients.yaml the client wall: 45 entries, 44 distinct names
```
## Three decisions worth knowing
**The client wall is type, not logos.** Forty-five names in one typeface at one
size on an even grid. Nothing is an image, so nothing can break, and no client is
visually ranked above another. Ten client marks do exist in Simple Icons; none is
enabled, because using a company's mark on a public portfolio is a permission
question for Sam, not a design choice for an agent.
**Diagrams carry both themes in one file.** Not two files that can drift: one
`<style>` block with the light palette and the same selectors repeated under
`prefers-color-scheme: dark`. The eight files therefore share one palette by
construction.
**Code excerpts are copied, never retyped.** Including the defects. An excerpt
that has been tidied is a claim, not evidence — and this site's whole argument is
that the claims are checkable.
## What is wired and what is not
| DoD item | State | Evidence |
|---|---|---|
| Client wall reads as deliberate, no broken images, no placeholders | **assets done, markup not** | `logos/clients.yaml` — 45 entries, zero image references by design. The grid is CSS and belongs to Builder |
| Eight diagrams, both themes, readable at 1× and on mobile | **done** | `VERIFY.md` §1–§3: 0 geometry findings, 8/8 resolve two palettes, rendered widths measured |
| Six code exhibits with a two-sentence rationale each | **done** | `code/manifest.yaml`, `VERIFY.md` §5 |
| Every image optimised; total weight budgeted and recorded | **assets done, page weight not** | `BUDGET.md`. Zero rasters. A full page cannot be weighed until a page exists |
| Lighthouse ≥ 95 / 95 after assets | **not measurable yet** | Needs a served page (Phase 3) |
| Archive never linked or exposed | **done** | `VERIFY.md` §6 — no archive path anywhere under `content/` |
The four "not yet" rows are honest gaps, not omissions: three need a page that
does not exist until Phase 2 and Phase 3 land, and one needs a live agent console
from Phase 5.
## Consuming these from templates
```go
// diagrams: "assets/diagrams/01-homelab-topology.svg"
<img src="/assets/diagrams/01-homelab-topology.svg"
alt="{{ .Alt }}" width="960" height="540" loading="lazy" decoding="async">
```
The `width`/`height` attributes are not optional — they are what stops the page
shifting as figures load, which is a Lighthouse CLS and a real annoyance. `alt`
comes from `manifest.yaml`; if it is set to `""` the SVG's own `<title>`/`<desc>`
are not read out.
Figures go inside a horizontally scrollable wrapper (`diagrams/README.md`), or a
960 px schematic becomes 5 px type on a phone.
## Adding an asset
1. Put the file in the right folder; no external references of any kind.
2. Add an entry to the folder's `manifest.yaml` with `alt` and, for code,
`rationale` and `honest_note`.
3. Re-measure and update `BUDGET.md`. A budget nobody updates is a wish.

View File

@@ -1,124 +0,0 @@
# Phase 7 — verification record
What was checked, how, and what it returned. Every block below is pasted output,
not a description of output.
Run 2026-10-26 on `.13`, headless Chrome for measurement.
## 1. Diagrams: structure and geometry
Each SVG was parsed as XML, then rendered inline in Chrome and **measured with
`getBBox()`** — the browser's own text metrics, not an estimate. The check
reported text overflow outside its box, partial overlap between boxes, elements
outside the viewBox, empty text nodes and unlabelled boxes.
```
01-homelab-topology.svg OK
02-agent-architecture.svg OK
03-rag-pipeline.svg OK
04-edge-architecture.svg OK
05-distributed-audio.svg OK
06-media-pipeline.svg OK
07-cost-resource-engineering.svg OK
08-era-timeline.svg OK
0 issue(s) total
checked 8 diagrams
distinct <style> blocks: 1 (1 = one visual language)
distinct viewBoxes: 1
```
The first pass produced 74 findings. They were fixed in the source of the
diagrams, not suppressed in the checker: shortened labels, widened boxes,
removed empty text nodes. The final run is clean.
**"One visual language" is one number: `distinct <style> blocks: 1`.** All eight
files carry a byte-identical style block, so there is no second palette to drift.
## 2. Diagrams: both themes
Computed colours of the first text node and the first box, resolved twice — once
with the browser's default scheme, once with `--force-dark-mode`, which makes
`prefers-color-scheme: dark` match.
```
01-homelab-topology.svg light rgb(27, 34, 43) rgb(244, 246, 248) → dark rgb(231, 237, 244) rgb(24, 32, 40) OK
02-agent-architecture.svg light rgb(27, 34, 43) rgb(244, 246, 248) → dark rgb(231, 237, 244) rgb(24, 32, 40) OK
03-rag-pipeline.svg light rgb(27, 34, 43) rgb(244, 246, 248) → dark rgb(231, 237, 244) rgb(24, 32, 40) OK
04-edge-architecture.svg light rgb(27, 34, 43) rgb(244, 246, 248) → dark rgb(231, 237, 244) rgb(24, 32, 40) OK
05-distributed-audio.svg light rgb(27, 34, 43) rgb(244, 246, 248) → dark rgb(231, 237, 244) rgb(24, 32, 40) OK
06-media-pipeline.svg light rgb(27, 34, 43) rgb(233, 241, 253) → dark rgb(231, 237, 244) rgb(17, 37, 61) OK
07-cost-resource-engineering.svg light rgb(27, 34, 43) rgb(230, 244, 242) → dark rgb(231, 237, 244) rgb(13, 38, 36) OK
08-era-timeline.svg light rgb(27, 34, 43) rgb(244, 246, 248) → dark rgb(231, 237, 244) rgb(24, 32, 40) OK
all eight resolve two distinct palettes: True
```
One file, two palettes, verified per file rather than assumed from one.
## 3. Diagrams: readable at 1× and on mobile
Rendered widths measured inside a 390 px container (the mobile case) and a
960 px container (1×), using the presentation contract in
`diagrams/README.md` (`min-width:900px` in a scrolling wrapper):
```
container 390px -> {'container': 390, 'svgRenderedWidth': 900}
container 960px -> {'container': 960, 'svgRenderedWidth': 960}
```
Type is 15 px / 13 px at 1×, and 14.1 px / 12.2 px at the 900 px minimum —
arithmetic from the rendered width over the 960 viewBox. Scaled to the container
instead, the same labels would render at 5 px, which is unreadable; that is why
the wrapper is a requirement and not a styling preference.
## 4. Accessibility
Every file carries `role="img"`, `<title>` and `<desc>`. `desc` length per file:
219, 230, 285, 223, 193, 197, 260, 234 characters — a real alternative text, not
a filename. The manifest repeats each `desc` verbatim as `alt`, so the page and
the screen reader cannot disagree.
## 5. Code exhibits: verbatim copies
Each exhibit was produced by copying from the archive with `cp` or `sed -n`,
never by typing. Line counts as reported by `wc -l`:
```
36 01-actionscript-class.as
42 02-js-easing-core.js
27 03-php-encode-op.php
37 04-typescript-service-layer.ts
28 05-embedded-cpp-interface.hpp
33 06-nix-derivation.nix
212 total
```
`03-php-encode-op.php` is two copied regions joined by one marked elision
(line 20 of the file). Nothing else was altered.
## 6. Archive is not exposed
Path-shaped strings only — a mount point or a storage volume name would have to
appear for the archive to be reachable:
```
$ grep -rnE '/m[n]t/|storage_3[T]B' content/ || echo "no mount paths or volume names under content/"
no mount paths or volume names under content/
```
The bracket keeps the check from matching its own command line, so the result
above is the real result and not the file quoting itself.
No matches. The archive is not linked, named, or reachable from anything under
`content/`. Provenance is recorded as "private archive, path withheld" — the
words only, never the location.
## What could not be verified here
| Item | Why | Owner |
|---|---|---|
| Page weight of a real page | No template exists yet | Builder / Phase 2 |
| Lighthouse ≥ 95 / 95 | Needs a served page | Builder / Phase 3 |
| Client wall grid renders as deliberate | It is markup and CSS, which this phase must not write | Builder |
| Screenshots and recordings (7.4) | The console is Phase 5; staging it would be fabrication | Phase 5, then this phase |

View File

@@ -1,36 +0,0 @@
package thebeer {
import utils.Bg;
import com.greensock.TweenMax;
import flash.display.MovieClip;
import flash.events.Event;
/**
* @author samrolfe
*/
public class Siren extends MovieClip {
private var targ : Bg;
private var delays : Number;
private var wide: Number;
private var high: Number;
public function Siren(col:Number,t:Bg,d:Number) {
delays=d;
targ=t;
x=targ.x;
y=targ.y;
wide=this.width;
high=this.high;
TweenMax.to(this,0,{autoAlpha:0,colorMatrixFilter:{colorize:col}});
}
private function setPos(e:Event) : void {
x=parent[targ].x;
y=parent[targ].y;
}
public function flash() : void {
TweenMax.to(this,0,{autoAlpha:1,width:wide,height:high});
TweenMax.to(this,.1,{width:0,height:0,repeat:5,repeatDelay:delays,yoyo:true,onComplete:finish});
}

View File

@@ -1,42 +0,0 @@
$(mySplitTextTarg).each(function(i){
tweenData.letter=$(this);
tweenData.text_static.letter=$(this);
tweenData.text_text_to.letter=$(this);
tweenData.text_text_from.letter=$(this);
if(text_effect_type_animation=="standard"){
if(tweenData.ignoreItems=="on" && Math.random()<tweenData.ignore_amount)return;
if(!keepHide(tweenData,i,$(this).text()))return;
var transforms=getTransforms(tweenData);
//currenttime,start,change,duration
if(tweenData.staggerTimeRnd=="random")stagger=min_max_random(0,tweenData.staggerTime);
// stagger=positionsCubic(tweenData.alternatecount, 0, tweenData.duration, tweenData.total)
var ease=tweenData.easeFunction;
// var ease="easeInCubic";
stime=tweenData.duration;
if(tweenData.direc=="in"){
stagger=jQuery.easing[ease](null,tweenData.alternatecount, 0, tweenData.duration, tweenData.total)+tweenData.startWait
transforms.delay=stagger;
TweenMax.from($(this), tweenData.duration, transforms);
tt=tweenData.duration+stagger;
}
if(tweenData.direc=="out"){
stagger=jQuery.easing[ease](null,tweenData.alternatecount, 0, tweenData.duration, tweenData.total)
transforms.delay=stagger;
transforms.onComplete=tweenRemove($(this),stagger+stime+tweenData.removeWait)
TweenMax.to($(this), tweenData.duration, transforms);
tt=tweenData.duration+stagger+tweenData.removeWait+1;
}
setCssTweens(stime,text_effect_type_animation,tweenData,tweenData.text_static,tweenData.text_text_to,tweenData.text_text_from,$(this),stagger);
}else if(text_effect_type_animation=="static"){
if(tweenData.text_static.ignoreItems=="on" && Math.random()<tweenData.text_static.ignore_amount)return;

View File

@@ -1,27 +0,0 @@
if($output_options->encode_media=="create_gif") {
$output_gif= ".." . $settings->directory . $output_file .$output_options->format .".gif";
$docmime= checkFinfo($input_file);
$doctype=explode("/",$docmime)[0];
if ($doctype!="video"){
$output_options->error_message= "We only create gifs from a video - it must be the first video in the media list.";
$returnFiles = returnFiles ( $output_options, $output_file .$output_options->format . '.mp4', "video/mp4", '', null );
return $returnFiles;
}
$cmd = "ffmpeg -i ".$input_file." -y -vf fps=".$settings->settings->movie_frame_rate.",scale=".$settings->settings->video_width.":".$settings->settings->video_height.",palettegen palette.png 2>&1";
// $cmd = "ffmpeg -i ".$input_file." -y -vf fps=10,scale=".$settings->settings->video_width.":".$settings->settings->video_height.",palettegen palette.png 2>&1";
$result=shell_exec($cmd);
$first_media=json_decode($settings->add_media)[0];
// $cmd = "ffmpeg -ss ".$first_media->add_media_start." -t ".$first_media->add_media_duration." -i ".$input_file." -i palette.png -y -filter_complex \"fps=10[x];[x][1:v]paletteuse\" ".$output_gif." ";
$cmd = "ffmpeg -ss ".$first_media->add_media_start." -t ".$first_media->add_media_duration." -i ".$input_file." -i palette.png -y -filter_complex \"fps=".$settings->settings->movie_frame_rate.",scale=".$settings->settings->video_width.":".$settings->settings->video_height.":flags=lanczos[x];[x][1:v]paletteuse\" ".$output_gif." ";
ffmpegMsg("ffmpeg","Creating Gif ", 0,$cmd);
/* [...] 34 lines elided: ImageMagick mogrify frame-optimisation
options, left disabled because they delayed output */
$output_options->edited_url= $output_options->options->directory.$output_file .$output_options->format . '.gif';
$output_options->edited_name= $output_file .$output_options->format . '.gif';
$output_options->edited_type = "image/gif";
$output_options->edited_filetype = explode ( "/", "image/gif");
insert_document($output_options->edited_url,"image/gif",$output_options->edited_name,"image","edited");
$returnFiles = returnFiles ( $output_options, $output_file .$output_options->format . '.mp4', "video/mp4", '', null );

View File

@@ -1,38 +0,0 @@
// In file: src/server/api/routers/commandTemplate.ts
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
export const commandTemplateRouter = createTRPCRouter({
// Procedure to get all templates
getAll: publicProcedure.query(({ ctx }) => {
return ctx.db.commandTemplate.findMany({
orderBy: { createdAt: "desc" },
});
}),
// Procedure to create a new template
create: publicProcedure
.input(z.object({
name: z.string().min(1),
type: z.string().min(1),
payload: z.record(z.unknown()), // Accepts any JSON object
}))
.mutation(async ({ ctx, input }) => {
return ctx.db.commandTemplate.create({
data: {
name: input.name,
type: input.type,
payload: input.payload,
},
});
}),
// Procedure to delete a template
delete: publicProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.commandTemplate.delete({
where: { id: input.id },
});
}),
});

View File

@@ -1,29 +0,0 @@
// WiFiMgr.hpp — the interface the rest of the firmware depends on
// WiFiMgr.hpp
#pragma once
#include <Arduino.h>
namespace WiFiMgr {
bool connectHome(const char* ssid, const char* psk, uint32_t timeoutMs = 15000);
void disconnect();
}
// WiFiMgr.cpp — the only translation unit that includes <WiFi.h>
// WiFiMgr.cpp
#include "WiFiMgr.hpp"
#include <WiFi.h> // ESP32 WiFi
namespace WiFiMgr {
bool connectHome(const char* ssid, const char* psk, uint32_t timeoutMs) {
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, psk);
uint32_t t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < timeoutMs) {
delay(200);
}
return WiFi.status() == WL_CONNECTED;
}
void disconnect() {
WiFi.disconnect(true, true);
WiFi.mode(WIFI_OFF);
}
}

View File

@@ -1,33 +0,0 @@
{ lib, stdenv, fetchurl }:
# tuios — terminal window manager (https://github.com/Gaurav-Gosain/tuios)
# OVERRIDE: nixpkgs ships tuios 0.7.0; the multi-machine `hosts` UX needs 0.9+.
# Official pure-Go prebuilt release binary (no runtime deps, runs bare on NixOS).
# To return to nixpkgs packaging: use plain `tuios` in home.packages again and
# delete this file (do this once nixpkgs ships tuios >= 0.10).
stdenv.mkDerivation {
pname = "tuios";
version = "0.10.1";
src = fetchurl {
url = "https://github.com/Gaurav-Gosain/tuios/releases/download/v0.10.1/tuios_0.10.1_Linux_x86_64.tar.gz";
sha256 = "85a4bf339a9a6793e0b3d15abdccd9a96ca8d2ff76072007ec99be31f1a16a5f";
};
# Tarball has no wrapping directory (tuios / LICENSE / README.md at root)
sourceRoot = ".";
installPhase = ''
runHook preInstall
install -Dm755 tuios $out/bin/tuios
runHook postInstall
'';
meta = with lib; {
description = "Terminal window manager — tiling panes, sessions that survive restarts (multi-machine daemon)";
homepage = "https://github.com/Gaurav-Gosain/tuios";
license = licenses.mit;
mainProgram = "tuios";
platforms = [ "x86_64-linux" ];
};
}

View File

@@ -1,48 +0,0 @@
# Phase 7.3 — code exhibits
Six excerpts, **212 lines**, spanning five eras. Evidence that the range behind
the claims is real, in a form an engineer can read in two minutes.
## The rules these follow
1. **Verbatim.** Every file is a byte-for-byte copy from the private archive.
Nothing is retyped, reformatted or tidied. A retyped excerpt proves nothing.
2. **Never linked.** The archive is not referenced, linked or served from the
public site. Provenance is recorded as "private archive" and the path is
withheld from this repository so it cannot leak into a page or a build.
3. **Two sentences on why it is good code.** `rationale` in `manifest.yaml`.
Not what the code does — that is what reading it is for.
4. **Honest notes.** Where the code is not good, `honest_note` says so. A 2012
procedural PHP script presented as elegant would undermine every other claim
on the site.
## The six
| # | Era | Language | Lines | What it demonstrates |
|---|---|---|---|---|
| 1 | Flash / ActionScript | AS3 | 36 | class, imports, encapsulation |
| 2 | jQuery + PHP | JavaScript | 42 | easing + timeline core |
| 3 | jQuery + PHP | PHP | 27 | one encode op + the FFmpeg piping |
| 4 | React + TypeScript | TypeScript | 37 | types, interfaces, service layer |
| 5 | Embedded C++ | C++ | 28 | interface split from implementation |
| 6 | Declarative infrastructure | Nix | 33 | a patch with its own removal condition |
Era 6 is the most modern and the least common. A short, correct, self-documenting
derivation that says when to delete itself is a better infrastructure signal than
a thousand lines of configuration.
## Renderer requirements
- Syntax highlighting happens **server-side** (chroma), on the server, at build
time. The client receives highlighted HTML. No browser-side highlighter.
- Line numbers are generated by the template, not embedded in the files.
- Files stay in `content/assets/code/` as plain text. They are not inlined into
a Go string, so the archive copy and the served copy cannot diverge silently.
- Total weight: 7 KB of source, which compresses well because it is code.
## Honest notes worth reading twice
- Exhibit 1 contains a latent typo, kept.
- Exhibit 3 is 27 lines out of a 3,400-line file, with one marked elision.
- Exhibit 4 shows the router rather than the schema it types against.
- Exhibit 5 is two files presented as one, because the point is the split.

View File

@@ -1,76 +0,0 @@
version: 1
authored: 2026-10-26
total_lines: 212
provenance: "private archive, copied 2026-10-26, path withheld"
exhibits:
- id: history-as3-class
order: 1
era: "Flash / ActionScript"
language: "ActionScript 3"
file: 01-actionscript-class.as
lines: 36
shows: "class, imports, encapsulation"
area: history
title: "A small class that hides its own state"
rationale: "Every field is private and explicitly typed, and the whole public surface is two methods, so a caller cannot push the object into a state it does not understand. The constructor takes what it needs as parameters instead of reaching for it, which is why the same class can drive every siren on the screen without knowing where any of them are."
honest_note: "Verbatim, including a latent typo on one private field (this.high rather than this.height). Left as found: the excerpt is evidence, not a portfolio piece."
- id: history-js-easing-engine
order: 2
era: "jQuery + PHP"
language: JavaScript
file: 02-js-easing-core.js
lines: 42
shows: "easing + timeline core"
area: history
title: "One curve, used twice"
rationale: "A named easing function is evaluated to give each letter its delay, so the spacing of an animation follows the same curve as its motion instead of a guessed linear stagger. Both directions build from one transform object, which is what stops the two halves of the same effect drifting apart over years of edits."
honest_note: "Excerpt from a 2,195-line file in which easing curves are used as a spacing distribution rather than only a timing function."
- id: history-php-media-pipeline
order: 3
era: "jQuery + PHP"
language: PHP
file: 03-php-encode-op.php
lines: 27
shows: "one encode op + the FFmpeg piping"
area: history
title: "Check the input, then encode in two passes"
rationale: "The real MIME type of the input is checked before any command is built, so a wrong file type returns a message instead of a half-written artefact. The encode takes two passes, generating a palette from the clip and then mapping frames onto it, which is the difference between a GIF that holds its colour and one that does not."
honest_note: "Row 20 is an elision marker: 34 lines of ImageMagick frame-optimisation options are omitted because they are disabled in the live code. This is 3,400 lines of procedural PHP with global state and interpolated shell commands; what is worth reading is the operational judgement, not the structure."
- id: history-typescript-service-layer
order: 4
era: "React + TypeScript"
language: TypeScript
file: 04-typescript-service-layer.ts
lines: 37
shows: "types, interfaces, service layer"
area: history
title: "Validate at the edge, then do one thing"
rationale: "Input is checked by a schema at the boundary of each procedure, so the data layer receives a shape it can trust rather than one it hopes for. Each procedure is a thin call on the database context, which keeps the router a description of the API rather than a second copy of the queries."
honest_note: "The types come from a Prisma schema in the same project (Device, Telemetry and Command models). The router is shown because it is where the two meet."
- id: edge-embedded-cpp-interface
order: 5
era: "Embedded C++"
language: C++
file: 05-embedded-cpp-interface.hpp
lines: 28
shows: "interface vs implementation"
area: edge
title: "The hardware stops at the header"
rationale: "The header declares one function and one constant and includes nothing but Arduino.h, so a caller compiles without pulling the ESP32 WiFi stack into its translation unit. Only the implementation file knows about station mode, polling and millisecond timeouts, and it returns a boolean so the caller decides what a failed join means."
honest_note: "Header and implementation are shown together, with a comment marking the boundary, because the point of the exhibit is the split rather than either file alone."
- id: platform-nix-derivation
order: 6
era: "Declarative infrastructure"
language: Nix
file: 06-nix-derivation.nix
lines: 33
shows: "fetchurl + hash, override rationale, removal condition"
area: platform
title: "A patch that says when to delete itself"
rationale: "The file records its own removal condition, delete it once nixpkgs ships 0.10, which is the part of an override that usually goes missing and leaves the next reader guessing. Pinning by fetchurl with a verified hash keeps the build reproducible while the reason for the pin lives in the file rather than in a commit message nobody will read."
honest_note: "The whole file, unedited. It is the newest exhibit and one of the shortest, and it is the one that best predicts how this engineer works now."

View File

@@ -1,111 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-labelledby="ttl dsc">
<title id="ttl">Homelab topology</title>
<desc id="dsc">Three hosts with their roles and the trust boundaries between them: public internet reaches Caddy on .35, which forwards over the LAN to services bound to 127.0.0.1 on .13. .27 is build and dev with no public listeners.</desc>
<style>
text{font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#1b222b;font-size:15px}
.hd{font-size:15px;font-weight:600}
.sub{font-size:13px;fill:#5b6673}
.cap{font-size:12px;fill:#5b6673;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
.big{font-size:19px;font-weight:600}
.node{fill:#f4f6f8;stroke:#c3ccd6;stroke-width:1.4}
.node-a{fill:#e9f1fd;stroke:#1a6bc4;stroke-width:1.4}
.node-t{fill:#e6f4f2;stroke:#0f766e;stroke-width:1.4}
.node-w{fill:#fdf1e3;stroke:#b45309;stroke-width:1.4}
.zone{fill:none;stroke:#aab4c0;stroke-width:1.4;stroke-dasharray:7 6}
.rule{stroke:#d0d7df;stroke-width:1}
.ln{stroke:#6b7684;stroke-width:1.6;fill:none}
.ln.d{stroke-dasharray:6 5}
.ln.a{stroke:#1a6bc4}
.ln.t{stroke:#0f766e}
.ln.w{stroke:#b45309}
.mN{fill:#6b7684}
.mA{fill:#1a6bc4}
.mT{fill:#0f766e}
.mW{fill:#b45309}
.dot{fill:#6b7684}
.tick{stroke:#aab4c0;stroke-width:1.2}
@media (prefers-color-scheme: dark){
text{fill:#e7edf4}
.sub,.cap{fill:#98a4b3}
.node{fill:#182028;stroke:#35424f}
.node-a{fill:#11253d;stroke:#5aa2f0}
.node-t{fill:#0d2624;stroke:#38b3a6}
.node-w{fill:#2a1d10;stroke:#d98b3f}
.zone{stroke:#43525f}
.rule{stroke:#2c3743}
.ln{stroke:#8496a6}
.ln.a{stroke:#5aa2f0}
.ln.t{stroke:#38b3a6}
.ln.w{stroke:#d98b3f}
.mN{fill:#8496a6}
.mA{fill:#5aa2f0}
.mT{fill:#38b3a6}
.mW{fill:#d98b3f}
.dot{fill:#8496a6}
.tick{stroke:#43525f}
}
</style>
<defs>
<marker id="aN" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mN" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mA" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mT" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mW" d="M0,0 L10,5 L0,10 z"/></marker>
</defs>
<text x="24" y="40" class="big">Homelab topology — hosts, roles, trust boundaries</text>
<rect x="24" y="74" width="150" height="152" rx="10" class="zone"/>
<text x="36" y="94" class="cap">public internet</text>
<rect x="38" y="110" width="122" height="62" rx="7" class="node "/>
<text x="99.0" y="139.0" class="hd" text-anchor="middle">Visitors</text>
<text x="99.0" y="157.0" class="sub" text-anchor="middle">no host access</text>
<path d="M174,148 L200,148" class="ln a" marker-end="url(#aA)"/>
<rect x="206" y="74" width="228" height="300" rx="7" class="node "/>
<text x="220" y="100" class="hd">.35 sam-Ubuntu1</text>
<text x="220" y="120" class="sub">public edge · 2 CPUs · 14 GiB</text>
<path d="M206,132 L434,132" class="rule"/>
<rect x="220" y="146" width="200" height="48" rx="7" class="node node-a"/>
<text x="320.0" y="168.0" class="hd" text-anchor="middle">Caddy :443</text>
<text x="320.0" y="186.0" class="sub" text-anchor="middle">the only public listener</text>
<rect x="220" y="208" width="200" height="44" rx="7" class="node "/>
<text x="320.0" y="235.0" class="hd" text-anchor="middle">Gitea · Immich · Wiki.js</text>
<rect x="220" y="262" width="200" height="44" rx="7" class="node "/>
<text x="320.0" y="289.0" class="hd" text-anchor="middle">Nextcloud · Penpot</text>
<text x="220" y="330" class="sub">terminates 443 for</text>
<text x="220" y="348" class="sub">*.lab.audasmedia.com.au</text>
<path d="M434,170 L462,170" class="ln a" marker-end="url(#aA)"/>
<rect x="468" y="74" width="268" height="366" rx="7" class="node "/>
<text x="482" y="100" class="hd">.13 nixos-desktop</text>
<text x="482" y="120" class="sub">runtime · 12 CPUs · 2.3 GiB free</text>
<path d="M468,132 L736,132" class="rule"/>
<rect x="482" y="146" width="240" height="46" rx="7" class="node node-a"/>
<text x="602.0" y="167.0" class="hd" text-anchor="middle">Go app :8096</text>
<text x="602.0" y="185.0" class="sub" text-anchor="middle">templ · HTMX · SSE · SQLite</text>
<rect x="482" y="200" width="240" height="42" rx="7" class="node "/>
<text x="602.0" y="226.0" class="hd" text-anchor="middle">Qdrant :6333 / :6334</text>
<rect x="482" y="250" width="240" height="42" rx="7" class="node "/>
<text x="602.0" y="269.0" class="hd" text-anchor="middle">Prefect worker</text>
<text x="602.0" y="287.0" class="sub" text-anchor="middle">content/ → chunk → embed</text>
<rect x="482" y="300" width="240" height="42" rx="7" class="node node-t"/>
<text x="602.0" y="319.0" class="hd" text-anchor="middle">OmniRoute</text>
<text x="602.0" y="337.0" class="sub" text-anchor="middle">existing · the only model gateway</text>
<rect x="482" y="350" width="240" height="42" rx="7" class="node "/>
<text x="602.0" y="369.0" class="hd" text-anchor="middle">Whisper · Piper</text>
<text x="602.0" y="387.0" class="sub" text-anchor="middle">speech in, speech out</text>
<text x="482" y="414" class="sub">every listener on 127.0.0.1</text>
<path d="M736,200 L764,200" class="ln d " marker-end="url(#aN)"/>
<text x="750.0" y="193.0" class="sub" text-anchor="middle">LAN</text>
<rect x="766" y="74" width="170" height="220" rx="7" class="node "/>
<text x="780" y="100" class="hd">.27</text>
<text x="780" y="120" class="sub">sam-4screen-desktop</text>
<path d="M766,132 L936,132" class="rule"/>
<rect x="780" y="146" width="142" height="44" rx="7" class="node "/>
<text x="851.0" y="173.0" class="hd" text-anchor="middle">content/ + source</text>
<rect x="780" y="200" width="142" height="44" rx="7" class="node "/>
<text x="851.0" y="227.0" class="hd" text-anchor="middle">agent fleet</text>
<text x="780" y="268" class="sub">build / dev · no public</text>
<text x="780" y="286" class="sub">listeners</text>
<rect x="206" y="396" width="730" height="62" rx="10" class="zone"/>
<text x="218" y="416" class="cap">trust boundary — outside this boundary nothing is reachable from the internet</text>
<text x="220" y="438" class="sub">Public → Caddy :443 on .35 → LAN → 127.0.0.1 services on .13. New footprint ≤ 1.0 GiB · ≤ 4 containers.</text>
<text x="24" y="486" class="sub">Solid = network path. Dashed = trust boundary. Ports are those the app actually binds.</text>
<text x="936" y="486" class="sub" text-anchor="end">measured 2026-10-17 · reference/stack.md</text>
</svg>

Before

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -1,98 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-labelledby="ttl dsc">
<title id="ttl">Agent architecture</title>
<desc id="dsc">The operator reaches a fleet of agent sessions through one inbox. Paperclip is the control plane and delegates to peer projects, which answer the Hub read-only. Every model call in the estate passes through one gateway, OmniRoute.</desc>
<style>
text{font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#1b222b;font-size:15px}
.hd{font-size:15px;font-weight:600}
.sub{font-size:13px;fill:#5b6673}
.cap{font-size:12px;fill:#5b6673;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
.big{font-size:19px;font-weight:600}
.node{fill:#f4f6f8;stroke:#c3ccd6;stroke-width:1.4}
.node-a{fill:#e9f1fd;stroke:#1a6bc4;stroke-width:1.4}
.node-t{fill:#e6f4f2;stroke:#0f766e;stroke-width:1.4}
.node-w{fill:#fdf1e3;stroke:#b45309;stroke-width:1.4}
.zone{fill:none;stroke:#aab4c0;stroke-width:1.4;stroke-dasharray:7 6}
.rule{stroke:#d0d7df;stroke-width:1}
.ln{stroke:#6b7684;stroke-width:1.6;fill:none}
.ln.d{stroke-dasharray:6 5}
.ln.a{stroke:#1a6bc4}
.ln.t{stroke:#0f766e}
.ln.w{stroke:#b45309}
.mN{fill:#6b7684}
.mA{fill:#1a6bc4}
.mT{fill:#0f766e}
.mW{fill:#b45309}
.dot{fill:#6b7684}
.tick{stroke:#aab4c0;stroke-width:1.2}
@media (prefers-color-scheme: dark){
text{fill:#e7edf4}
.sub,.cap{fill:#98a4b3}
.node{fill:#182028;stroke:#35424f}
.node-a{fill:#11253d;stroke:#5aa2f0}
.node-t{fill:#0d2624;stroke:#38b3a6}
.node-w{fill:#2a1d10;stroke:#d98b3f}
.zone{stroke:#43525f}
.rule{stroke:#2c3743}
.ln{stroke:#8496a6}
.ln.a{stroke:#5aa2f0}
.ln.t{stroke:#38b3a6}
.ln.w{stroke:#d98b3f}
.mN{fill:#8496a6}
.mA{fill:#5aa2f0}
.mT{fill:#38b3a6}
.mW{fill:#d98b3f}
.dot{fill:#8496a6}
.tick{stroke:#43525f}
}
</style>
<defs>
<marker id="aN" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mN" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mA" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mT" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mW" d="M0,0 L10,5 L0,10 z"/></marker>
</defs>
<text x="24" y="40" class="big">Agent architecture — operator, control plane, peers, one gateway</text>
<rect x="360" y="62" width="240" height="56" rx="7" class="node "/>
<text x="480.0" y="88.0" class="hd" text-anchor="middle">Operator</text>
<text x="480.0" y="106.0" class="sub" text-anchor="middle">one inbox for runs and approvals</text>
<path d="M480,118 L480,146" class="ln " marker-end="url(#aN)"/>
<rect x="330" y="146" width="300" height="62" rx="7" class="node "/>
<text x="480.0" y="175.0" class="hd" text-anchor="middle">TUIOS fleet</text>
<text x="480.0" y="193.0" class="sub" text-anchor="middle">four hosts · survive disconnect</text>
<path d="M400,208 L300,236" class="ln " marker-end="url(#aN)"/>
<path d="M480,208 L480,236" class="ln " marker-end="url(#aN)"/>
<path d="M560,208 L680,236" class="ln " marker-end="url(#aN)"/>
<rect x="40" y="236" width="250" height="132" rx="7" class="node "/>
<text x="54" y="262" class="hd">Hub</text>
<text x="54" y="282" class="sub">house entry point for every project</text>
<path d="M40,292 L290,292" class="rule"/>
<text x="54" y="314" class="sub">/capabilities · /ask (read-only)</text>
<text x="54" y="334" class="sub">/notify · Apprise delivery</text>
<text x="54" y="356" class="sub">no write path to peers</text>
<rect x="355" y="236" width="250" height="132" rx="7" class="node "/>
<text x="369" y="262" class="hd">Paperclip</text>
<text x="369" y="282" class="sub">control plane · the system of record</text>
<path d="M355,292 L605,292" class="rule"/>
<text x="369" y="314" class="sub">issues · runs · approvals</text>
<text x="369" y="334" class="sub">agent identities · budgets</text>
<text x="369" y="356" class="sub">routines · blocked-by edges</text>
<rect x="670" y="236" width="250" height="132" rx="7" class="node "/>
<text x="684" y="262" class="hd">Peer data projects</text>
<text x="684" y="282" class="sub">own service, own port, own folder</text>
<path d="M670,292 L920,292" class="rule"/>
<text x="684" y="314" class="sub">resume (this site)</text>
<text x="684" y="334" class="sub">compass_nhs · email_agent</text>
<text x="684" y="356" class="sub">IoT estate · signage</text>
<path d="M605,302 L670,302" class="ln " marker-end="url(#aN)"/>
<text x="637.5" y="295.0" class="sub" text-anchor="middle">delegates</text>
<path d="M290,320 L355,320" class="ln " marker-end="url(#aN)"/>
<rect x="200" y="404" width="560" height="76" rx="7" class="node node-t"/>
<text x="480" y="432" class="hd" text-anchor="middle">OmniRoute — the only model gateway</text>
<text x="480" y="454" class="sub" text-anchor="middle">fast · mid · escalation tier | token caps | monthly ceiling | trace log</text>
<path d="M480,368 L480,404" class="ln t" marker-end="url(#aT)"/>
<path d="M120,368 L260,404" class="ln t" marker-end="url(#aT)"/>
<path d="M795,368 L700,404" class="ln t" marker-end="url(#aT)"/>
<text x="480" y="502" class="sub" text-anchor="middle">OpenRouter · DeepSeek · Gemini · MiniMax · Qwen</text>
<text x="480" y="520" class="sub" text-anchor="middle">reachable only through the gateway</text>
<text x="24" y="498" class="sub">Enforced by configuration, not convention: no service holds a provider key, so none can bypass the gateway.</text>
</svg>

Before

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -1,106 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-labelledby="ttl dsc">
<title id="ttl">Retrieval pipeline</title>
<desc id="dsc">Left, offline ingest: content files are validated, chunked with metadata, embedded and upserted into Qdrant, while the same files render to static pages with no AI in the path. Right, per-visitor query: guardrail, query graph, filtered retrieval, then a streamed answer with citations.</desc>
<style>
text{font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#1b222b;font-size:15px}
.hd{font-size:15px;font-weight:600}
.sub{font-size:13px;fill:#5b6673}
.cap{font-size:12px;fill:#5b6673;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
.big{font-size:19px;font-weight:600}
.node{fill:#f4f6f8;stroke:#c3ccd6;stroke-width:1.4}
.node-a{fill:#e9f1fd;stroke:#1a6bc4;stroke-width:1.4}
.node-t{fill:#e6f4f2;stroke:#0f766e;stroke-width:1.4}
.node-w{fill:#fdf1e3;stroke:#b45309;stroke-width:1.4}
.zone{fill:none;stroke:#aab4c0;stroke-width:1.4;stroke-dasharray:7 6}
.rule{stroke:#d0d7df;stroke-width:1}
.ln{stroke:#6b7684;stroke-width:1.6;fill:none}
.ln.d{stroke-dasharray:6 5}
.ln.a{stroke:#1a6bc4}
.ln.t{stroke:#0f766e}
.ln.w{stroke:#b45309}
.mN{fill:#6b7684}
.mA{fill:#1a6bc4}
.mT{fill:#0f766e}
.mW{fill:#b45309}
.dot{fill:#6b7684}
.tick{stroke:#aab4c0;stroke-width:1.2}
@media (prefers-color-scheme: dark){
text{fill:#e7edf4}
.sub,.cap{fill:#98a4b3}
.node{fill:#182028;stroke:#35424f}
.node-a{fill:#11253d;stroke:#5aa2f0}
.node-t{fill:#0d2624;stroke:#38b3a6}
.node-w{fill:#2a1d10;stroke:#d98b3f}
.zone{stroke:#43525f}
.rule{stroke:#2c3743}
.ln{stroke:#8496a6}
.ln.a{stroke:#5aa2f0}
.ln.t{stroke:#38b3a6}
.ln.w{stroke:#d98b3f}
.mN{fill:#8496a6}
.mA{fill:#5aa2f0}
.mT{fill:#38b3a6}
.mW{fill:#d98b3f}
.dot{fill:#8496a6}
.tick{stroke:#43525f}
}
</style>
<defs>
<marker id="aN" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mN" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mA" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mT" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mW" d="M0,0 L10,5 L0,10 z"/></marker>
</defs>
<text x="24" y="40" class="big">Retrieval pipeline — ingest and query, side by side</text>
<path d="M480,64 L480,470" class="zone"/>
<text x="40" y="76" class="cap">ingest — runs offline</text>
<text x="504" y="76" class="cap">query — runs per visitor</text>
<rect x="40" y="92" width="250" height="48" rx="7" class="node "/>
<text x="165.0" y="114.0" class="hd" text-anchor="middle">content/ in git</text>
<text x="165.0" y="132.0" class="sub" text-anchor="middle">Markdown + front-matter + assets</text>
<path d="M165.0,140 L165.0,164" class="ln " marker-end="url(#aN)"/>
<rect x="40" y="164" width="250" height="48" rx="7" class="node "/>
<text x="165.0" y="186.0" class="hd" text-anchor="middle">validate front-matter</text>
<text x="165.0" y="204.0" class="sub" text-anchor="middle">schema is frozen; build fails closed</text>
<path d="M165.0,212 L165.0,236" class="ln " marker-end="url(#aN)"/>
<rect x="40" y="236" width="250" height="48" rx="7" class="node "/>
<text x="165.0" y="258.0" class="hd" text-anchor="middle">chunk + tag metadata</text>
<text x="165.0" y="276.0" class="sub" text-anchor="middle">area · level · confidence · period</text>
<path d="M165.0,284 L165.0,308" class="ln " marker-end="url(#aN)"/>
<rect x="40" y="308" width="250" height="48" rx="7" class="node node-t"/>
<text x="165.0" y="330.0" class="hd" text-anchor="middle">embed via OmniRoute</text>
<text x="165.0" y="348.0" class="sub" text-anchor="middle">one gateway, one trace log</text>
<path d="M165.0,356 L165.0,380" class="ln t" marker-end="url(#aT)"/>
<rect x="180" y="380" width="300" height="56" rx="7" class="node node-t"/>
<text x="330.0" y="406.0" class="hd" text-anchor="middle">Qdrant — payload-filtered store</text>
<text x="330.0" y="424.0" class="sub" text-anchor="middle">one point per chunk</text>
<rect x="40" y="400" width="116" height="64" rx="7" class="node node-a"/>
<text x="98.0" y="430.0" class="hd" text-anchor="middle">templ build</text>
<text x="98.0" y="448.0" class="sub" text-anchor="middle">Mode A pages</text>
<path d="M98,212 L98,400" class="ln a" marker-end="url(#aA)"/>
<text x="98.0" y="299.0" class="sub" text-anchor="middle">same file</text>
<text x="106" y="300" class="sub">no AI in this path</text>
<text x="106" y="318" class="sub">(acceptance test A1)</text>
<rect x="504" y="92" width="250" height="48" rx="7" class="node "/>
<text x="629.0" y="114.0" class="hd" text-anchor="middle">visitor prompt</text>
<text x="629.0" y="132.0" class="sub" text-anchor="middle">plain English, untrusted</text>
<path d="M629.0,140 L629.0,164" class="ln w" marker-end="url(#aW)"/>
<rect x="504" y="164" width="250" height="48" rx="7" class="node node-w"/>
<text x="629.0" y="186.0" class="hd" text-anchor="middle">guardrail: classify → allow / deny</text>
<text x="629.0" y="204.0" class="sub" text-anchor="middle">fails closed, no tools, no write path</text>
<path d="M629.0,212 L629.0,236" class="ln " marker-end="url(#aN)"/>
<rect x="504" y="236" width="250" height="48" rx="7" class="node "/>
<text x="629.0" y="258.0" class="hd" text-anchor="middle">LangGraph query graph</text>
<text x="629.0" y="276.0" class="sub" text-anchor="middle">route → retrieve → cite → compose</text>
<path d="M629.0,284 L629.0,308" class="ln " marker-end="url(#aN)"/>
<rect x="504" y="308" width="250" height="48" rx="7" class="node "/>
<text x="629.0" y="330.0" class="hd" text-anchor="middle">retrieve: top-k, filtered by area</text>
<text x="629.0" y="348.0" class="sub" text-anchor="middle">filtered before it is searched</text>
<path d="M629.0,356 L629.0,380" class="ln " marker-end="url(#aN)"/>
<rect x="504" y="380" width="250" height="56" rx="7" class="node node-a"/>
<text x="629.0" y="406.0" class="hd" text-anchor="middle">SSE stream + citations</text>
<text x="629.0" y="424.0" class="sub" text-anchor="middle">first token &lt;4 s · every claim cited</text>
<path d="M480,408 L504,408" class="ln " marker-end="url(#aN)"/>
<text x="24" y="492" class="sub">Ingest and query share one store and one schema.</text>
<text x="24" y="512" class="sub">Static pages and corpus are built from the same files, so the two modes cannot drift.</text>
</svg>

Before

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -1,111 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-labelledby="ttl dsc">
<title id="ttl">Edge architecture</title>
<desc id="dsc">Constrained devices publish over LoRa, Zigbee or WiFi to a single MQTT broker, which feeds one automation layer that drives actuation. Retained configuration, telemetry and last-will presence let devices recover unattended.</desc>
<style>
text{font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#1b222b;font-size:15px}
.hd{font-size:15px;font-weight:600}
.sub{font-size:13px;fill:#5b6673}
.cap{font-size:12px;fill:#5b6673;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
.big{font-size:19px;font-weight:600}
.node{fill:#f4f6f8;stroke:#c3ccd6;stroke-width:1.4}
.node-a{fill:#e9f1fd;stroke:#1a6bc4;stroke-width:1.4}
.node-t{fill:#e6f4f2;stroke:#0f766e;stroke-width:1.4}
.node-w{fill:#fdf1e3;stroke:#b45309;stroke-width:1.4}
.zone{fill:none;stroke:#aab4c0;stroke-width:1.4;stroke-dasharray:7 6}
.rule{stroke:#d0d7df;stroke-width:1}
.ln{stroke:#6b7684;stroke-width:1.6;fill:none}
.ln.d{stroke-dasharray:6 5}
.ln.a{stroke:#1a6bc4}
.ln.t{stroke:#0f766e}
.ln.w{stroke:#b45309}
.mN{fill:#6b7684}
.mA{fill:#1a6bc4}
.mT{fill:#0f766e}
.mW{fill:#b45309}
.dot{fill:#6b7684}
.tick{stroke:#aab4c0;stroke-width:1.2}
@media (prefers-color-scheme: dark){
text{fill:#e7edf4}
.sub,.cap{fill:#98a4b3}
.node{fill:#182028;stroke:#35424f}
.node-a{fill:#11253d;stroke:#5aa2f0}
.node-t{fill:#0d2624;stroke:#38b3a6}
.node-w{fill:#2a1d10;stroke:#d98b3f}
.zone{stroke:#43525f}
.rule{stroke:#2c3743}
.ln{stroke:#8496a6}
.ln.a{stroke:#5aa2f0}
.ln.t{stroke:#38b3a6}
.ln.w{stroke:#d98b3f}
.mN{fill:#8496a6}
.mA{fill:#5aa2f0}
.mT{fill:#38b3a6}
.mW{fill:#d98b3f}
.dot{fill:#8496a6}
.tick{stroke:#43525f}
}
</style>
<defs>
<marker id="aN" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mN" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mA" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mT" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mW" d="M0,0 L10,5 L0,10 z"/></marker>
</defs>
<text x="24" y="40" class="big">Edge architecture — devices, one broker, one automation layer</text>
<rect x="24" y="74" width="220" height="236" rx="7" class="node "/>
<text x="38" y="100" class="hd">Devices</text>
<text x="38" y="120" class="sub">20+ nodes · 9 zones</text>
<path d="M24,132 L244,132" class="rule"/>
<rect x="38" y="146" width="192" height="40" rx="7" class="node "/>
<text x="134.0" y="164.0" class="hd" text-anchor="middle">10 × ESP32</text>
<text x="134.0" y="182.0" class="sub" text-anchor="middle">sensors, relays, buttons</text>
<rect x="38" y="194" width="192" height="40" rx="7" class="node "/>
<text x="134.0" y="212.0" class="hd" text-anchor="middle">2 × Zigbee routers</text>
<text x="134.0" y="230.0" class="sub" text-anchor="middle">hand-built</text>
<rect x="38" y="242" width="192" height="40" rx="7" class="node "/>
<text x="134.0" y="260.0" class="hd" text-anchor="middle">8 × Pi audio endpoints</text>
<text x="134.0" y="278.0" class="sub" text-anchor="middle">Snapcast clients</text>
<text x="38" y="300" class="sub">radio: LoRa · Zigbee · WiFi</text>
<path d="M244,190 L288,190" class="ln a" marker-end="url(#aA)"/>
<rect x="288" y="74" width="210" height="236" rx="7" class="node "/>
<text x="302" y="100" class="hd">MQTT broker</text>
<text x="302" y="120" class="sub">the only shared state</text>
<path d="M288,132 L498,132" class="rule"/>
<rect x="302" y="146" width="182" height="40" rx="7" class="node "/>
<text x="393.0" y="171.0" class="hd" text-anchor="middle">retained config</text>
<rect x="302" y="194" width="182" height="40" rx="7" class="node "/>
<text x="393.0" y="219.0" class="hd" text-anchor="middle">status · telemetry</text>
<rect x="302" y="242" width="182" height="40" rx="7" class="node "/>
<text x="393.0" y="260.0" class="hd" text-anchor="middle">LWT presence</text>
<text x="393.0" y="278.0" class="sub" text-anchor="middle">last will = offline</text>
<text x="302" y="300" class="sub">reconnect + re-subscribe</text>
<path d="M498,190 L542,190" class="ln " marker-end="url(#aN)"/>
<rect x="542" y="74" width="194" height="236" rx="7" class="node "/>
<text x="556" y="100" class="hd">Automation</text>
<text x="556" y="120" class="sub">20 automations · 9 zones</text>
<path d="M542,132 L736,132" class="rule"/>
<text x="556" y="156" class="sub">trigger → rule → action</text>
<text x="556" y="182" class="sub">9 lighting zones</text>
<text x="556" y="208" class="sub">auditable in one place</text>
<text x="556" y="240" class="sub">rules live in config,</text>
<text x="556" y="258" class="sub">not in firmware</text>
<path d="M736,190 L780,190" class="ln t" marker-end="url(#aT)"/>
<rect x="780" y="74" width="156" height="236" rx="7" class="node "/>
<text x="794" y="100" class="hd">Actuation</text>
<path d="M780,132 L936,132" class="rule"/>
<text x="794" y="158" class="sub">lights · relays</text>
<text x="794" y="184" class="sub">speech out</text>
<text x="794" y="210" class="sub">displays</text>
<text x="794" y="236" class="sub">doors · gates</text>
<rect x="24" y="332" width="442" height="84" rx="7" class="node node-t"/>
<text x="38" y="358" class="hd">Power and duty cycle</text>
<text x="38" y="380" class="sub">solar where there is no mains · deep sleep between reports</text>
<text x="38" y="400" class="sub">the budget that gets designed first, not last</text>
<rect x="494" y="332" width="442" height="84" rx="7" class="node node-w"/>
<text x="508" y="358" class="hd">Failures kept in the record</text>
<text x="508" y="380" class="sub">lizard feeder destroyed by the lizard · cameras postponed</text>
<text x="508" y="400" class="sub">doorbell being rebuilt with a ToF sensor</text>
<text x="24" y="452" class="sub">Same shape at every size: device → one broker → one automation layer → actuation.</text>
<text x="24" y="472" class="sub">Devices never talk to each other, so a node that returns from a power cut re-announces itself and nothing else changes.</text>
<text x="936" y="500" class="sub" text-anchor="end">20+ nodes = 10 ESP32 · 2 Zigbee routers · 8 Pi · reference/content-map.md</text>
</svg>

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -1,111 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-labelledby="ttl dsc">
<title id="ttl">Distributed audio</title>
<desc id="dsc">Music and speech sources feed one Snapcast server, which streams one clock-synchronised mix to eight Pi endpoints. Voice announcements arrive as a priority lane: duck the music, speak, restore.</desc>
<style>
text{font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#1b222b;font-size:15px}
.hd{font-size:15px;font-weight:600}
.sub{font-size:13px;fill:#5b6673}
.cap{font-size:12px;fill:#5b6673;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
.big{font-size:19px;font-weight:600}
.node{fill:#f4f6f8;stroke:#c3ccd6;stroke-width:1.4}
.node-a{fill:#e9f1fd;stroke:#1a6bc4;stroke-width:1.4}
.node-t{fill:#e6f4f2;stroke:#0f766e;stroke-width:1.4}
.node-w{fill:#fdf1e3;stroke:#b45309;stroke-width:1.4}
.zone{fill:none;stroke:#aab4c0;stroke-width:1.4;stroke-dasharray:7 6}
.rule{stroke:#d0d7df;stroke-width:1}
.ln{stroke:#6b7684;stroke-width:1.6;fill:none}
.ln.d{stroke-dasharray:6 5}
.ln.a{stroke:#1a6bc4}
.ln.t{stroke:#0f766e}
.ln.w{stroke:#b45309}
.mN{fill:#6b7684}
.mA{fill:#1a6bc4}
.mT{fill:#0f766e}
.mW{fill:#b45309}
.dot{fill:#6b7684}
.tick{stroke:#aab4c0;stroke-width:1.2}
@media (prefers-color-scheme: dark){
text{fill:#e7edf4}
.sub,.cap{fill:#98a4b3}
.node{fill:#182028;stroke:#35424f}
.node-a{fill:#11253d;stroke:#5aa2f0}
.node-t{fill:#0d2624;stroke:#38b3a6}
.node-w{fill:#2a1d10;stroke:#d98b3f}
.zone{stroke:#43525f}
.rule{stroke:#2c3743}
.ln{stroke:#8496a6}
.ln.a{stroke:#5aa2f0}
.ln.t{stroke:#38b3a6}
.ln.w{stroke:#d98b3f}
.mN{fill:#8496a6}
.mA{fill:#5aa2f0}
.mT{fill:#38b3a6}
.mW{fill:#d98b3f}
.dot{fill:#8496a6}
.tick{stroke:#43525f}
}
</style>
<defs>
<marker id="aN" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mN" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mA" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mT" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mW" d="M0,0 L10,5 L0,10 z"/></marker>
</defs>
<text x="24" y="40" class="big">Distributed audio — sources, synchronous endpoints, voice injection</text>
<rect x="24" y="92" width="200" height="120" rx="7" class="node "/>
<text x="38" y="118" class="hd">Sources</text>
<path d="M24,130 L224,130" class="rule"/>
<text x="38" y="156" class="sub">Spotify → Mopidy</text>
<text x="38" y="180" class="sub">Piper / MiniMax TTS</text>
<text x="38" y="204" class="sub">announcements</text>
<path d="M224,152 L268,152" class="ln " marker-end="url(#aN)"/>
<rect x="268" y="92" width="210" height="120" rx="7" class="node "/>
<text x="282" y="118" class="hd">Snapcast server</text>
<text x="282" y="138" class="sub">one clock, one stream</text>
<path d="M268,150 L478,150" class="rule"/>
<text x="282" y="176" class="sub">per-client latency offset</text>
<text x="282" y="198" class="sub">new clients join mid-stream</text>
<path d="M478,152 L522,152" class="ln t" marker-end="url(#aT)"/>
<rect x="522" y="74" width="414" height="156" rx="7" class="node "/>
<text x="536" y="100" class="hd">8 endpoints — Pi 2W</text>
<text x="536" y="120" class="sub">one stream, eight speakers, in sync</text>
<path d="M522,132 L936,132" class="rule"/>
<rect x="536" y="146" width="42" height="42" rx="7" class="node "/>
<text x="557.0" y="172.0" class="hd" text-anchor="middle">1</text>
<rect x="586" y="146" width="42" height="42" rx="7" class="node "/>
<text x="607.0" y="172.0" class="hd" text-anchor="middle">2</text>
<rect x="636" y="146" width="42" height="42" rx="7" class="node "/>
<text x="657.0" y="172.0" class="hd" text-anchor="middle">3</text>
<rect x="686" y="146" width="42" height="42" rx="7" class="node "/>
<text x="707.0" y="172.0" class="hd" text-anchor="middle">4</text>
<rect x="736" y="146" width="42" height="42" rx="7" class="node "/>
<text x="757.0" y="172.0" class="hd" text-anchor="middle">5</text>
<rect x="786" y="146" width="42" height="42" rx="7" class="node "/>
<text x="807.0" y="172.0" class="hd" text-anchor="middle">6</text>
<rect x="836" y="146" width="42" height="42" rx="7" class="node "/>
<text x="857.0" y="172.0" class="hd" text-anchor="middle">7</text>
<rect x="886" y="146" width="42" height="42" rx="7" class="node "/>
<text x="907.0" y="172.0" class="hd" text-anchor="middle">8</text>
<text x="536" y="214" class="sub">music in one room does not arrive in another room late</text>
<rect x="24" y="262" width="470" height="152" rx="7" class="node node-t"/>
<text x="38" y="288" class="hd">Voice injection — the part that is not just streaming</text>
<rect x="38" y="304" width="136" height="38" rx="7" class="node "/>
<text x="106.0" y="328.0" class="hd" text-anchor="middle">TTS audio in</text>
<path d="M174,323 L200,323" class="ln t" marker-end="url(#aT)"/>
<rect x="200" y="304" width="126" height="38" rx="7" class="node "/>
<text x="263.0" y="328.0" class="hd" text-anchor="middle">duck music</text>
<path d="M326,323 L352,323" class="ln t" marker-end="url(#aT)"/>
<rect x="352" y="304" width="120" height="38" rx="7" class="node "/>
<text x="412.0" y="328.0" class="hd" text-anchor="middle">announce</text>
<text x="38" y="372" class="sub">then restore the music to its previous level</text>
<text x="38" y="392" class="sub">an announcement is a priority lane over the music</text>
<rect x="516" y="262" width="420" height="152" rx="7" class="node "/>
<text x="530" y="288" class="hd">Why it is built this way</text>
<text x="530" y="312" class="sub">· one source of time, not one source of audio</text>
<text x="530" y="336" class="sub">· endpoints are disposable; the server holds the state</text>
<text x="530" y="360" class="sub">· voice reuses the same endpoint fleet</text>
<text x="530" y="384" class="sub">· ducking is a mix decision, not a volume hack</text>
<text x="24" y="452" class="sub">Eight synchronised endpoints with music ducking for voice.</text>
<text x="24" y="472" class="sub">The endpoints are the cheapest part of the system, which is the point.</text>
<text x="936" y="500" class="sub" text-anchor="end">confidence: delivered · 8 endpoints · Pi 2W</text>
</svg>

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -1,92 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-labelledby="ttl dsc">
<title id="ttl">Media pipeline</title>
<desc id="dsc">Five stages from download to library: fingerprinting gives every file a natural key so re-runs converge, audit verifies the copy, and review is a deliberate human gate before anything is published.</desc>
<style>
text{font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#1b222b;font-size:15px}
.hd{font-size:15px;font-weight:600}
.sub{font-size:13px;fill:#5b6673}
.cap{font-size:12px;fill:#5b6673;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
.big{font-size:19px;font-weight:600}
.node{fill:#f4f6f8;stroke:#c3ccd6;stroke-width:1.4}
.node-a{fill:#e9f1fd;stroke:#1a6bc4;stroke-width:1.4}
.node-t{fill:#e6f4f2;stroke:#0f766e;stroke-width:1.4}
.node-w{fill:#fdf1e3;stroke:#b45309;stroke-width:1.4}
.zone{fill:none;stroke:#aab4c0;stroke-width:1.4;stroke-dasharray:7 6}
.rule{stroke:#d0d7df;stroke-width:1}
.ln{stroke:#6b7684;stroke-width:1.6;fill:none}
.ln.d{stroke-dasharray:6 5}
.ln.a{stroke:#1a6bc4}
.ln.t{stroke:#0f766e}
.ln.w{stroke:#b45309}
.mN{fill:#6b7684}
.mA{fill:#1a6bc4}
.mT{fill:#0f766e}
.mW{fill:#b45309}
.dot{fill:#6b7684}
.tick{stroke:#aab4c0;stroke-width:1.2}
@media (prefers-color-scheme: dark){
text{fill:#e7edf4}
.sub,.cap{fill:#98a4b3}
.node{fill:#182028;stroke:#35424f}
.node-a{fill:#11253d;stroke:#5aa2f0}
.node-t{fill:#0d2624;stroke:#38b3a6}
.node-w{fill:#2a1d10;stroke:#d98b3f}
.zone{stroke:#43525f}
.rule{stroke:#2c3743}
.ln{stroke:#8496a6}
.ln.a{stroke:#5aa2f0}
.ln.t{stroke:#38b3a6}
.ln.w{stroke:#d98b3f}
.mN{fill:#8496a6}
.mA{fill:#5aa2f0}
.mT{fill:#38b3a6}
.mW{fill:#d98b3f}
.dot{fill:#8496a6}
.tick{stroke:#43525f}
}
</style>
<defs>
<marker id="aN" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mN" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mA" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mT" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mW" d="M0,0 L10,5 L0,10 z"/></marker>
</defs>
<text x="24" y="40" class="big">Media pipeline — download, identify, verify, review, publish</text>
<rect x="24" y="96" width="154" height="84" rx="7" class="node node-a"/>
<text x="101.0" y="136.0" class="hd" text-anchor="middle">download</text>
<text x="101.0" y="154.0" class="sub" text-anchor="middle">acquire the file</text>
<text x="101" y="168" class="cap" text-anchor="middle">stage 1</text>
<path d="M178,138 L214,138" class="ln " marker-end="url(#aN)"/>
<rect x="214" y="96" width="154" height="84" rx="7" class="node "/>
<text x="291.0" y="136.0" class="hd" text-anchor="middle">fingerprint</text>
<text x="291.0" y="154.0" class="sub" text-anchor="middle">identify what it is</text>
<text x="291" y="168" class="cap" text-anchor="middle">stage 2</text>
<path d="M368,138 L404,138" class="ln " marker-end="url(#aN)"/>
<rect x="404" y="96" width="154" height="84" rx="7" class="node "/>
<text x="481.0" y="136.0" class="hd" text-anchor="middle">audit</text>
<text x="481.0" y="154.0" class="sub" text-anchor="middle">verify the copy</text>
<text x="481" y="168" class="cap" text-anchor="middle">stage 3</text>
<path d="M558,138 L594,138" class="ln " marker-end="url(#aN)"/>
<rect x="594" y="96" width="154" height="84" rx="7" class="node node-w"/>
<text x="671.0" y="136.0" class="hd" text-anchor="middle">review</text>
<text x="671.0" y="154.0" class="sub" text-anchor="middle">human gate</text>
<text x="671" y="168" class="cap" text-anchor="middle">stage 4</text>
<path d="M748,138 L784,138" class="ln " marker-end="url(#aN)"/>
<rect x="784" y="96" width="154" height="84" rx="7" class="node node-a"/>
<text x="861.0" y="136.0" class="hd" text-anchor="middle">library</text>
<text x="861.0" y="154.0" class="sub" text-anchor="middle">publish + serve</text>
<text x="861" y="168" class="cap" text-anchor="middle">stage 5</text>
<rect x="214" y="236" width="342" height="96" rx="7" class="node node-w"/>
<text x="228" y="262" class="hd">Gate, not a queue</text>
<text x="228" y="284" class="sub">a file that fails fingerprint or audit</text>
<text x="228" y="304" class="sub">stops where a person can see it</text>
<rect x="590" y="236" width="346" height="96" rx="7" class="node "/>
<text x="604" y="262" class="hd">Idempotent by identity</text>
<text x="604" y="284" class="sub">the fingerprint is the natural key, so a</text>
<text x="604" y="304" class="sub">re-run converges instead of duplicating</text>
<rect x="24" y="356" width="912" height="92" rx="7" class="node "/>
<text x="38" y="382" class="hd">The same shape at 10 files and at 10,000</text>
<text x="38" y="404" class="sub">every stage is a separate step with its own failure mode, so a bad stage can be re-run alone</text>
<text x="38" y="426" class="sub">the review stage is deliberately manual: automating it would move the risk, not remove it</text>
<text x="24" y="480" class="sub">Encoding, thumbnailing and fingerprinting at scale, with a human gate before anything is published.</text>
</svg>

Before

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -1,95 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-labelledby="ttl dsc">
<title id="ttl">Cost and resource engineering</title>
<desc id="dsc">A bar chart of the per-service memory caps drawn to scale against the 1024 MiB ceiling, totalling 970 MiB across four containers, beside the rules that hold the ceiling: equal memory and swap limits, loopback binding, a hard container count and a cost ceiling.</desc>
<style>
text{font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#1b222b;font-size:15px}
.hd{font-size:15px;font-weight:600}
.sub{font-size:13px;fill:#5b6673}
.cap{font-size:12px;fill:#5b6673;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
.big{font-size:19px;font-weight:600}
.node{fill:#f4f6f8;stroke:#c3ccd6;stroke-width:1.4}
.node-a{fill:#e9f1fd;stroke:#1a6bc4;stroke-width:1.4}
.node-t{fill:#e6f4f2;stroke:#0f766e;stroke-width:1.4}
.node-w{fill:#fdf1e3;stroke:#b45309;stroke-width:1.4}
.zone{fill:none;stroke:#aab4c0;stroke-width:1.4;stroke-dasharray:7 6}
.rule{stroke:#d0d7df;stroke-width:1}
.ln{stroke:#6b7684;stroke-width:1.6;fill:none}
.ln.d{stroke-dasharray:6 5}
.ln.a{stroke:#1a6bc4}
.ln.t{stroke:#0f766e}
.ln.w{stroke:#b45309}
.mN{fill:#6b7684}
.mA{fill:#1a6bc4}
.mT{fill:#0f766e}
.mW{fill:#b45309}
.dot{fill:#6b7684}
.tick{stroke:#aab4c0;stroke-width:1.2}
@media (prefers-color-scheme: dark){
text{fill:#e7edf4}
.sub,.cap{fill:#98a4b3}
.node{fill:#182028;stroke:#35424f}
.node-a{fill:#11253d;stroke:#5aa2f0}
.node-t{fill:#0d2624;stroke:#38b3a6}
.node-w{fill:#2a1d10;stroke:#d98b3f}
.zone{stroke:#43525f}
.rule{stroke:#2c3743}
.ln{stroke:#8496a6}
.ln.a{stroke:#5aa2f0}
.ln.t{stroke:#38b3a6}
.ln.w{stroke:#d98b3f}
.mN{fill:#8496a6}
.mA{fill:#5aa2f0}
.mT{fill:#38b3a6}
.mW{fill:#d98b3f}
.dot{fill:#8496a6}
.tick{stroke:#43525f}
}
</style>
<defs>
<marker id="aN" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mN" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mA" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mT" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mW" d="M0,0 L10,5 L0,10 z"/></marker>
</defs>
<text x="24" y="40" class="big">Cost and resource engineering — the budget, measured</text>
<text x="24" y="68" class="sub">Every service declares a ceiling. The ceiling is a measurement, not an estimate: .13 already runs on 2.3 GiB free with 8.1 GiB of swap in use.</text>
<text x="40" y="104" class="cap">per-service cap (MiB)</text>
<text x="40" y="136" class="sub">Go app</text>
<rect x="200" y="112" width="33.6" height="30" rx="5" class="bar node-a"/>
<text x="243.6" y="133" class="sub">120 MiB</text>
<text x="40" y="182" class="sub">Qdrant</text>
<rect x="200" y="158" width="112.00000000000001" height="30" rx="5" class="bar node-t"/>
<text x="322.0" y="179" class="sub">400 MiB</text>
<text x="40" y="228" class="sub">Prefect worker</text>
<rect x="200" y="204" width="42.00000000000001" height="30" rx="5" class="bar "/>
<text x="252.0" y="225" class="sub">150 MiB</text>
<text x="40" y="274" class="sub">Guardrail (if local)</text>
<rect x="200" y="250" width="84.00000000000001" height="30" rx="5" class="bar "/>
<text x="294.0" y="271" class="sub">300 MiB</text>
<path d="M200,352 L486.72,352" class="tick"/>
<text x="200" y="374" class="sub">0</text>
<text x="486.72" y="374" class="sub" text-anchor="end">1024 MiB = the whole new footprint</text>
<path d="M200,340 L200,362" class="tick"/>
<rect x="200" y="372" width="287" height="56" rx="7" class="node node-t"/>
<text x="343.5" y="398.0" class="hd" text-anchor="middle">970 MiB of 1024 MiB</text>
<text x="343.5" y="416.0" class="sub" text-anchor="middle">4 containers · measured, not asserted</text>
<rect x="520" y="92" width="416" height="336" rx="10" class="zone"/>
<text x="532" y="112" class="cap">rules that hold the ceiling</text>
<rect x="536" y="118" width="384" height="52" rx="7" class="node "/>
<text x="728.0" y="142.0" class="hd" text-anchor="middle">mem_limit == memswap_limit</text>
<text x="728.0" y="160.0" class="sub" text-anchor="middle">a service cannot buy headroom from host swap</text>
<rect x="536" y="180" width="384" height="52" rx="7" class="node "/>
<text x="728.0" y="204.0" class="hd" text-anchor="middle">bind 127.0.0.1</text>
<text x="728.0" y="222.0" class="sub" text-anchor="middle">Caddy is the only public listener</text>
<rect x="536" y="242" width="384" height="52" rx="7" class="node "/>
<text x="728.0" y="266.0" class="hd" text-anchor="middle">4 containers, not 14</text>
<text x="728.0" y="284.0" class="sub" text-anchor="middle">one process per job, no sidecars</text>
<rect x="536" y="304" width="384" height="52" rx="7" class="node "/>
<text x="728.0" y="328.0" class="hd" text-anchor="middle">cost has a ceiling too</text>
<text x="728.0" y="346.0" class="sub" text-anchor="middle">median session ≤ US$0.01 · monthly refusal</text>
<text x="536" y="384" class="sub">verified with docker inspect, not with intent</text>
<rect x="24" y="446" width="912" height="56" rx="7" class="node node-w"/>
<text x="480.0" y="472.0" class="hd" text-anchor="middle">Host reality on .13: 15 GiB total · 2.3 GiB free · 8.1 GiB swap in use</text>
<text x="480.0" y="490.0" class="sub" text-anchor="middle">exceeding the budget destabilises services people rely on</text>
<text x="24" y="524" class="sub">Source: reference/stack.md, hosts measured 2026-10-17. The caps are the contract; the bar chart is the contract drawn to scale.</text>
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

View File

@@ -1,106 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 540" width="960" height="540" role="img" aria-labelledby="ttl dsc">
<title id="ttl">Era timeline</title>
<desc id="dsc">Five consecutive platform eras from Flash and ActionScript through jQuery and PHP, React and TypeScript, server-driven Go with HTMX, to agent systems, on an axis from 2000 to 2026. Era boundaries are approximate; the bands show order.</desc>
<style>
text{font-family:ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;fill:#1b222b;font-size:15px}
.hd{font-size:15px;font-weight:600}
.sub{font-size:13px;fill:#5b6673}
.cap{font-size:12px;fill:#5b6673;letter-spacing:.06em;text-transform:uppercase;font-weight:600}
.big{font-size:19px;font-weight:600}
.node{fill:#f4f6f8;stroke:#c3ccd6;stroke-width:1.4}
.node-a{fill:#e9f1fd;stroke:#1a6bc4;stroke-width:1.4}
.node-t{fill:#e6f4f2;stroke:#0f766e;stroke-width:1.4}
.node-w{fill:#fdf1e3;stroke:#b45309;stroke-width:1.4}
.zone{fill:none;stroke:#aab4c0;stroke-width:1.4;stroke-dasharray:7 6}
.rule{stroke:#d0d7df;stroke-width:1}
.ln{stroke:#6b7684;stroke-width:1.6;fill:none}
.ln.d{stroke-dasharray:6 5}
.ln.a{stroke:#1a6bc4}
.ln.t{stroke:#0f766e}
.ln.w{stroke:#b45309}
.mN{fill:#6b7684}
.mA{fill:#1a6bc4}
.mT{fill:#0f766e}
.mW{fill:#b45309}
.dot{fill:#6b7684}
.tick{stroke:#aab4c0;stroke-width:1.2}
@media (prefers-color-scheme: dark){
text{fill:#e7edf4}
.sub,.cap{fill:#98a4b3}
.node{fill:#182028;stroke:#35424f}
.node-a{fill:#11253d;stroke:#5aa2f0}
.node-t{fill:#0d2624;stroke:#38b3a6}
.node-w{fill:#2a1d10;stroke:#d98b3f}
.zone{stroke:#43525f}
.rule{stroke:#2c3743}
.ln{stroke:#8496a6}
.ln.a{stroke:#5aa2f0}
.ln.t{stroke:#38b3a6}
.ln.w{stroke:#d98b3f}
.mN{fill:#8496a6}
.mA{fill:#5aa2f0}
.mT{fill:#38b3a6}
.mW{fill:#d98b3f}
.dot{fill:#8496a6}
.tick{stroke:#43525f}
}
</style>
<defs>
<marker id="aN" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mN" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aA" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mA" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mT" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="aW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="mW" d="M0,0 L10,5 L0,10 z"/></marker>
</defs>
<text x="24" y="40" class="big">Twenty-five years, five platform shifts</text>
<rect x="28" y="96" width="176" height="118" rx="7" class="node"/>
<text x="42" y="126" class="cap">01</text>
<text x="42" y="144" class="hd">Flash / ActionScript</text>
<text x="42" y="168" class="sub">classes, imports,</text>
<text x="42" y="186" class="sub">encapsulation</text>
<text x="42" y="212" class="sub">client wall</text>
<path d="M205,155 L211,155" class="ln"/>
<rect x="212" y="96" width="176" height="118" rx="7" class="node"/>
<text x="226" y="126" class="cap">02</text>
<text x="226" y="144" class="hd">jQuery + PHP</text>
<text x="226" y="168" class="sub">media pipelines,</text>
<text x="226" y="186" class="sub">FFmpeg, ImageMagick</text>
<text x="226" y="212" class="sub">delivery at scale</text>
<path d="M389,155 L395,155" class="ln"/>
<rect x="396" y="96" width="176" height="118" rx="7" class="node"/>
<text x="410" y="126" class="cap">03</text>
<text x="410" y="144" class="hd">React + TypeScript</text>
<text x="410" y="168" class="sub">typed service</text>
<text x="410" y="186" class="sub">layers, tRPC</text>
<text x="410" y="212" class="sub">typed front ends</text>
<path d="M573,155 L579,155" class="ln"/>
<rect x="580" y="96" width="176" height="118" rx="7" class="node node-a"/>
<text x="594" y="126" class="cap">04</text>
<text x="594" y="144" class="hd">Server-driven</text>
<text x="594" y="168" class="sub">Go + templ + HTMX,</text>
<text x="594" y="186" class="sub">SQLite</text>
<text x="594" y="212" class="sub">this site</text>
<path d="M757,155 L763,155" class="ln"/>
<rect x="764" y="96" width="176" height="118" rx="7" class="node node-t"/>
<text x="778" y="126" class="cap">05</text>
<text x="778" y="144" class="hd">Agent systems</text>
<text x="778" y="168" class="sub">harnesses, routing,</text>
<text x="778" y="186" class="sub">retrieval</text>
<text x="778" y="212" class="sub">AI estate</text>
<path d="M28,268 L932,268" class="ln"/>
<path d="M28,262 L28,274" class="tick"/>
<text x="28" y="292" class="hd" text-anchor="start">2000</text>
<path d="M546,262 L546,274" class="tick"/>
<text x="546" y="292" class="hd" text-anchor="middle">2020</text>
<path d="M932,262 L932,274" class="tick"/>
<text x="932" y="292" class="hd" text-anchor="end">2026</text>
<text x="28" y="314" class="sub">employed delivery</text>
<text x="546" y="314" class="sub">independent engineering practice</text>
<text x="932" y="314" class="sub" text-anchor="end">now</text>
<rect x="28" y="348" width="904" height="118" rx="7" class="node "/>
<text x="42" y="376" class="hd">What the five eras have in common</text>
<text x="42" y="400" class="sub">· every era was learned by shipping with it, not by reading about it</text>
<text x="42" y="424" class="sub">· the artefacts survive: a Flash class, an easing engine, a media pipeline, a typed service layer, a 33-line derivation</text>
<text x="42" y="448" class="sub">· the current era is the first where platform, agents and edge hardware are one system</text>
<text x="28" y="500" class="sub">Era boundaries are approximate: employed 2000–2020 per the résumé, independent practice 2020–.</text>
<text x="932" y="500" class="sub" text-anchor="end">six code exhibits span eras 1–5</text>
</svg>

Before

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -1,80 +0,0 @@
# Phase 7.2 — diagrams
Eight diagrams, one visual language, light and dark in one file each.
Authored 2026-10-26. Source of truth is the SVG files themselves: each one is
self-contained, with no external font, image or script reference.
## The language
| Part | Rule |
|---|---|
| Canvas | `viewBox="0 0 960 540"`, `width`/`height` set so a bare file view is 1× |
| Type | 19 px section title · 15 px heading · 13 px label · 12 px caption, all `ui-sans-serif` system stack |
| Boxes | `rx=7`, 1.4 px stroke, four semantic tones: neutral, accent (the thing being described), teal (store / gateway), amber (guard, failure, limit) |
| Arrows | solid = data or control flow, dashed = trust boundary or approximate relationship |
| Colour | greys carry structure; one blue accent, one teal, one amber, nothing else |
| Text | every `<text>` is real text in the SVG, so it scales, selects and reads to a screen reader |
Every file carries a **byte-identical `<style>` block**. That is the whole of the
consistency guarantee: there is one palette, defined once, repeated verbatim.
A check that compares the eight style blocks and finds one distinct value is the
evidence that the eight diagrams share a language.
## Both themes in one file
The `<style>` block defines the light palette, then repeats the same selectors
inside `@media (prefers-color-scheme: dark)`. No duplicated files, no theme
toggle needed in the page, no chance of a light and a dark copy drifting apart.
If the site later grows a manual theme toggle, the SVG must be inlined into the
page (not `<img>`) so page CSS can override the custom properties. Until then
`<img>` is correct: the file follows the reader's operating system.
| Role | Light | Dark |
|---|---|---|
| ink (text) | `#1b222b` | `#e7edf4` |
| panel (box fill) | `#f4f6f8` | `#182028` |
| muted (labels) | `#5b6673` | `#98a4b3` |
| accent | `#1a6bc4` | `#5aa2f0` |
| teal | `#0f766e` | `#38b3a6` |
| amber | `#b45309` | `#d98b3f` |
| box stroke | `#c3ccd6` | `#35424f` |
## Mobile
A 960 px schematic cannot be read at 360 px. The presentation contract is:
- wrap the figure in a horizontally scrollable container
- `svg { display:block; width:100%; min-width:900px; height:auto }`
At 1× (container ≥ 960 px) the type renders at 15 px / 13 px. At the 900 px
minimum inside a 390 px container it renders at 14.1 px / 12.2 px — the same
order as body text, and the figure scrolls rather than shrinking into
illegibility. A figure that is scaled to 360 px instead reaches 5 px type and is
not readable; that is why the contract is a requirement and not a suggestion.
## Accessibility
Each file carries `role="img"` and both `<title>` and `<desc>`. The `<desc>` is
the alternative text — 190–285 characters, describing what the diagram asserts
rather than naming its parts. If a template sets `alt=""` on the `<img>`, the
`<title>`/`<desc>` are not read out; the template must supply `alt` from the
manifest's `alt:` field.
## Files
| # | File | Area | Answers |
|---|---|---|---|
| 1 | `01-homelab-topology.svg` | platform | where it runs, and where the trust boundaries are |
| 2 | `02-agent-architecture.svg` | ai | how agents are organised and why one gateway |
| 3 | `03-rag-pipeline.svg` | ai | which side of the pipeline is AI and which side is not |
| 4 | `04-edge-architecture.svg` | edge | why devices never talk to each other |
| 5 | `05-distributed-audio.svg` | edge | how eight speakers stay in sync, and how voice gets in |
| 6 | `06-media-pipeline.svg` | products | where the human gate sits in a media pipeline |
| 7 | `07-cost-resource-engineering.svg` | platform | the memory ceiling, drawn to scale |
| 8 | `08-era-timeline.svg` | history | five platform shifts and what survived them |
Facts behind them come from `reference/stack.md`, `reference/content-map.md` and
`docs/phase-3-deploy.md`. Diagram 7 is numbers from a measurement, not an
illustration: if the caps change, the bars change.

View File

@@ -1,82 +0,0 @@
# Phase 7.2 — diagram manifest
#
# Machine-readable index for the templates. `alt` is copied verbatim from each
# file's <desc>, so the text a screen reader gets and the text in this manifest
# are the same string.
version: 1
authored: 2026-10-26
viewbox: "0 0 960 540"
themes: [light, dark] # both resolved from one file via prefers-color-scheme
format: svg-text # real text nodes, not glyph outlines
external_refs: none # no fonts, images or scripts referenced
presentation:
desktop: {css_width: "100%", rendered_type_px: [15, 13]}
mobile:
wrap: "overflow-x:auto"
css: "svg{display:block;width:100%;min-width:900px;height:auto}"
rendered_type_px: [14.1, 12.2]
diagrams:
- id: platform-homelab-topology
file: 01-homelab-topology.svg
area: platform
level: 2
title: "Homelab topology"
answers: "where it runs, and where the trust boundaries are"
alt: "Three hosts with their roles and the trust boundaries between them: public internet reaches Caddy on .35, which forwards over the LAN to services bound to 127.0.0.1 on .13. .27 is build and dev with no public listeners."
bytes: 7079
- id: ai-agent-architecture
file: 02-agent-architecture.svg
area: ai
level: 2
title: "Agent architecture"
answers: "how the agent estate is organised, and why there is one gateway"
alt: "The operator reaches a fleet of agent sessions through one inbox. Paperclip is the control plane and delegates to peer projects, which answer the Hub read-only. Every model call in the estate passes through one gateway, OmniRoute."
bytes: 6027
- id: ai-rag-pipeline
file: 03-rag-pipeline.svg
area: ai
level: 2
title: "Retrieval pipeline"
answers: "which side of the pipeline is AI and which side is not"
alt: "Left, offline ingest: content files are validated, chunked with metadata, embedded and upserted into Qdrant, while the same files render to static pages with no AI in the path. Right, per-visitor query: guardrail, query graph, filtered retrieval, then a streamed answer with citations."
bytes: 7010
- id: edge-architecture
file: 04-edge-architecture.svg
area: edge
level: 2
title: "Edge architecture"
answers: "why devices never talk to each other directly"
alt: "Constrained devices publish over LoRa, Zigbee or WiFi to a single MQTT broker, which feeds one automation layer that drives actuation. Retained configuration, telemetry and last-will presence let devices recover unattended."
bytes: 6896
- id: edge-distributed-audio
file: 05-distributed-audio.svg
area: edge
level: 2
title: "Distributed audio"
answers: "how eight endpoints stay in sync, and how voice gets in"
alt: "Music and speech sources feed one Snapcast server, which streams one clock-synchronised mix to eight Pi endpoints. Voice announcements arrive as a priority lane: duck the music, speak, restore."
bytes: 6833
- id: products-media-pipeline
file: 06-media-pipeline.svg
area: products
level: 2
title: "Media pipeline"
answers: "where the human gate sits in a media pipeline"
alt: "Five stages from download to library: fingerprinting gives every file a natural key so re-runs converge, audit verifies the copy, and review is a deliberate human gate before anything is published."
bytes: 5694
- id: platform-cost-resource-engineering
file: 07-cost-resource-engineering.svg
area: platform
level: 2
title: "Cost and resource engineering"
answers: "the memory ceiling, drawn to scale from measured caps"
alt: "A bar chart of the per-service memory caps drawn to scale against the 1024 MiB ceiling, totalling 970 MiB across four containers, beside the rules that hold the ceiling: equal memory and swap limits, loopback binding, a hard container count and a cost ceiling."
bytes: 6151
- id: history-era-timeline
file: 08-era-timeline.svg
area: history
level: 2
title: "Era timeline"
answers: "five platform shifts and what survived them"
alt: "Five consecutive platform eras from Flash and ActionScript through jQuery and PHP, React and TypeScript, server-driven Go with HTMX, to agent systems, on an axis from 2000 to 2026. Era boundaries are approximate; the bands show order."
bytes: 6039

View File

@@ -1,125 +0,0 @@
# Phase 7.1 — the client wall
#
# Decision: a typographic wall, not a logo collage. 45 entries, 44 distinct names, one
# typeface, one size, an even grid. Nothing is an image, so nothing can fail to
# load, and no name is more prominent than another.
#
# The Simple Icons audit below was performed for Phase 7. Ten marks exist. They
# are NOT enabled in this file: putting a client's mark on a public portfolio is
# a permission decision, not a design decision, so `mark: none` for every entry
# until Sam says otherwise.
version: 1
authored: 2026-10-26
render: typographic
mark_policy:
default: none
enable: "ask Sam before enabling any commercial mark; availability is recorded, permission is not implied"
icon_set: "Simple Icons (CC0 paths, trademarks remain with their owners)"
counts:
agencies_listed: 22
brands_listed: 23
entries_listed: 45
distinct_names: 44
claimed_by_plan: 46
note: >
The plan says 46 names and 23 agencies. The source list contains 22 agencies
and 23 brands, and 'Pearsons' appears in both groups, so the distinct count
is 44. No name has been invented to reach 46; the difference is flagged for
Sam rather than papered over.
corrections_applied:
- "Diajio -> Diageo (reference/content-map.md: factual correction, do not undo)"
- "AutoCAD listed as the product; the mark that exists is Autodesk's"
client_wall:
agencies:
- name: "Clemenger"
mark: none
- name: "Agency Republic"
mark: none
- name: "Brand Union"
mark: none
- name: "Jackpot Joy"
mark: none
- name: "Bryce Ford"
mark: none
- name: "RMG Connect"
mark: none
- name: "Sporting Index"
mark: none
- name: "Adventis Digital"
mark: none
- name: "Trinity St"
mark: none
- name: "Inferno"
mark: none
- name: "SAS Design"
mark: none
- name: "Ogilvy"
mark: none
- name: "Wunderman"
mark: none
- name: "Illumina"
mark: none
- name: "Barkers"
mark: none
- name: "Mook"
mark: none
- name: "Pearsons"
mark: none # available in Simple Icons; not enabled
- name: "Penguin"
mark: none
- name: "Draft London"
mark: none
- name: "Behan Consulting"
mark: none
- name: "MCM Entertainment"
mark: none
- name: "Carsales"
mark: none
brands:
- name: "Carlton Draught"
mark: none
- name: "Diageo"
mark: none
- name: "KPMG"
mark: none
- name: "McDonald's"
mark: none # available in Simple Icons; not enabled
- name: "Accenture"
mark: none # available in Simple Icons; not enabled
- name: "BBC National Archive"
mark: none
- name: "Bank of America"
mark: none # available in Simple Icons; not enabled
- name: "War of the Worlds"
mark: none
- name: "Madonna (Hard Candy)"
mark: none
- name: "PlayStation"
mark: none # available in Simple Icons; not enabled
- name: "HSBC"
mark: none # available in Simple Icons; not enabled
- name: "DHL"
mark: none # available in Simple Icons; not enabled
- name: "Reuters"
mark: none
- name: "Volvic"
mark: none
- name: "AutoCAD"
mark: none # available in Simple Icons; not enabled
attribution: "Autodesk" if n == "AutoCAD"
- name: "Channel 4"
mark: none # available in Simple Icons; not enabled
- name: "Pearsons"
mark: none # available in Simple Icons; not enabled
- name: "Shell"
mark: none # available in Simple Icons; not enabled
- name: "Stella Artois"
mark: none
- name: "Baileys Irish Cream"
mark: none
- name: "Parkgate Homes"
mark: none
- name: "Carlton Dry"
mark: none
- name: "Cottees Cordial"
mark: none

View File

@@ -0,0 +1,65 @@
---
id: edge-fleet
area: edge
level: 2
title: "Constrained devices · custom radio · and a record of what broke"
claim: "Twenty or more edge nodes across nine zones and twenty automations — solar power · deep sleep · LoRa and hand-built Zigbee — reporting into one automation layer with voice control on top."
confidence: running
period: "2020–"
tech: [ESP32, LoRa, Zigbee, MQTT, Home Assistant, Snapcast, Pi, PIR, mmWave]
metrics: [{label: "edge nodes", value: 20}, {label: "lighting zones", value: 9}, {label: "automations", value: 20}, {label: "hand-built Zigbee routers", value: 2}]
artefacts: [{type: doc, label: "Edge architecture — power · radio · deep sleep", url: "content/edge/l3/edge-architecture.md"}, {type: doc, label: "LoRa irrigation under solar power", url: "content/edge/l3/edge-lora-sprinkler.md"}, {type: doc, label: "Hand-built Zigbee routers and lighting", url: "content/edge/l3/edge-zigbee.md"}, {type: doc, label: "Distributed audio — eight endpoints with ducking", url: "content/edge/l3/edge-snapcast.md"}, {type: doc, label: "Front door — vision · voice · and a rebuild", url: "content/edge/l3/edge-front-door.md"}, {type: doc, label: "Sensors and actuation", url: "content/edge/l3/edge-sensors.md"}, {type: doc, label: "The failures, kept in the record", url: "content/edge/l3/edge-failures.md"}]
public: true
last_verified: 2026-10-26
tags: [edge, iot, esp32, lora, zigbee, audio]
---
# Constrained devices · custom radio · and a record of what broke
**Capability statement.** Devices that must run for months on a small battery, talk over radio that was chosen for range rather than convenience, and fail in ways that are visible. Twenty or more nodes, nine zones, twenty automations, and a voice layer on top of all of it.
## What is actually run
**Edge architecture.** Each node is designed around its power budget: a LiFePO battery with a solar controller where there is sun, deep sleep between wakes, and a wake source chosen per job. The sprinkler and the garden lights wake on radio rather than on a timer, so a command can arrive while the device is asleep.
**Radio.** Three links, each for a reason. LoRa for range and penetration on outdoor nodes — long enough to cover the garden and the shed without a repeater. Zigbee for the indoor lighting mesh, including two routers built by hand where the commercial mesh did not reach. Wi-Fi where a device is mains-powered and needs bandwidth, such as the audio endpoints and the camera path.
**Sensors and actuation.** PIR for movement, radar and mmWave for presence that a PIR cannot see — including a presence sensor built for a bathroom and a child's bedroom because the off-the-shelf options were wrong for the space. Relays and step-down converters for 240V loads, servos and a 16-channel PWM driver for mechanical jobs, solenoids for water, load cells for weight.
**Audio distribution.** Eight Snapcast endpoints on Pi hardware, fed by a Snapserver, with Spotify and a music server as sources. Every spoken announcement — automation notices, assistant replies, doorbell messages — plays across all eight endpoints with the music ducked underneath and restored afterwards.
**The automation layer.** Home Assistant holds the state and the routines; MQTT carries the messages. Nine lighting zones and twenty automations, with voice control on top, so a request spoken in one room is answered through the speakers in whichever room a person is standing.
**Cameras and vision.** The front door takes an image, logs it, has the image described, and speaks the result into the house before deciding what to do next. RFID tags on boxes and on trees resolve to records — inventory with prices for the boxes, care notes for the trees — written by an automation rather than typed by hand.
## Evidence
| Claim | Evidence | Artefact |
|---|---|---|
| Twenty or more nodes | Ten ESP32 devices · two hand-built Zigbee routers · eight Pi audio endpoints | [Edge architecture](l3/edge-architecture.md) |
| Radio chosen per job | LoRa outdoors with radio wake from deep sleep; Zigbee indoors with hand-built routers | [Irrigation](l3/edge-lora-sprinkler.md) · [Zigbee](l3/edge-zigbee.md) |
| Nine zones and twenty automations | Lighting zones and routines in the automation layer | [Zigbee and lighting](l3/edge-zigbee.md) |
| Eight endpoints with ducking | Snapcast clients on Pi hardware with music lowered under speech | [Distributed audio](l3/edge-snapcast.md) |
| Vision and voice at the door | Image capture · description · spoken response · two-way audio | [Front door](l3/edge-front-door.md) |
| Failures are stated | Lizard feeder destroyed · garden cameras postponed · doorbell being rebuilt | [Failures](l3/edge-failures.md) |
## Trade-offs and limits
- **Power decides the design.** Deep sleep means a device is unreachable for most of its life, so every routine has to tolerate a node that is asleep. That constraint is why radio wake exists at all.
- **Range over bandwidth.** LoRa is slow and payload-limited. It is chosen where a hundred bytes a day is enough and the alternative is a Wi-Fi access point in a garden bed.
- **Some things are still manual.** The garden automation is partly manual — some watering and lighting is scheduled, some is a person turning something on. The page says which is which instead of implying full coverage.
- **The failures cost money and are kept.** A lizard destroyed a feeder built for it. The garden cameras were postponed with the parts already bought. The doorbell is being rebuilt rather than declared finished. Those are the most honest evidence of production experience on this site.
## Drill-downs
- [Edge architecture — power · radio · deep sleep](l3/edge-architecture.md) — level 3
- [LoRa irrigation under solar power](l3/edge-lora-sprinkler.md) — level 3
- [Hand-built Zigbee routers and lighting](l3/edge-zigbee.md) — level 3
- [Distributed audio — eight endpoints with ducking](l3/edge-snapcast.md) — level 3
- [Front door — vision · voice · and a rebuild](l3/edge-front-door.md) — level 3
- [Sensors and actuation](l3/edge-sensors.md) — level 3
- [The failures, kept in the record](l3/edge-failures.md) — level 3
## Period and confidence
`period: 2020–`, anchored on the homelab year in the evidence notes. Confidence is `running` for the fleet as a whole; individual nodes carry their own label, and the ones being rebuilt or postponed are labelled that way on their pages.

View File

@@ -0,0 +1,25 @@
---
id: edge-overview
area: edge
level: 1
title: "Edge & Devices"
claim: "Builds and maintains a twenty-node edge fleet — constrained devices · custom radio · solar power · deep sleep · and sensors — reporting into a single automation layer."
confidence: running
period: "2020–"
tech: [ESP32, LoRa, Zigbee, MQTT, Home Assistant, Snapcast, Raspberry Pi]
metrics: [{label: "edge nodes", value: 20}, {label: "lighting zones", value: 9}, {label: "automations", value: 20}]
artefacts: [{type: doc, label: "Edge architecture — power · radio · deep sleep", url: "content/edge/l3/edge-architecture.md"}, {type: doc, label: "The failures, kept in the record", url: "content/edge/l3/edge-failures.md"}, {type: doc, label: "Distributed audio — eight endpoints with ducking", url: "content/edge/l3/edge-snapcast.md"}]
public: true
last_verified: 2026-10-26
tags: [edge, iot, esp32, lora, zigbee]
---
# Edge & Devices
**Claim.** Twenty or more nodes — ten ESP32 devices, two hand-built Zigbee routers and eight Pi audio endpoints — across nine lighting zones and twenty automations, all reporting into one automation layer.
**Status.** Running. Some of it is finished, some is being rebuilt, and at least one thing was destroyed by the animal it was built for. All three states are shown.
**Why it is credible.** These devices are outdoors, on solar, in deep sleep, and they must keep working when nobody is looking. The failures on this page cost real time and are documented as failures.
**Start here.** [The capability page](edge-fleet.md) covers the architecture, the radio, the audio and what broke.

View File

@@ -0,0 +1,31 @@
---
id: edge-architecture
area: edge
level: 3
title: "Artefact — Edge architecture · power · radio · deep sleep"
claim: "Each node is designed around its power budget — battery and solar · deep sleep between wakes · and a deliberate choice of wake source per job."
confidence: running
period: "2020–"
tech: [ESP32, LoRa, Zigbee, LiFePO, MQTT, Home Assistant]
metrics: [{label: "edge nodes", value: 20}, {label: "ESP32 devices", value: 10}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [edge, architecture, power, artefact]
---
# Artefact — Edge architecture · power · radio · deep sleep
**Status: stub.** One annotated diagram of a representative node and the radio path lands in Phase 7.
## What the artefact is
The per-node pattern: 240V step-down or solar with a LiFePO battery and charge controller, an ESP32 in deep sleep, a wake source (timer, radio, or a physical input), a relay or driver for the load, and an MQTT message into the automation layer. Around it, the reason each node uses LoRa, Zigbee or Wi-Fi.
## What it proves
Power budgeting and remote wake as design decisions, not hobby wiring — and an architecture that survived being repeated across twenty nodes.
## When it is delivered
Phase 7. Source: evidence notes section 1.

View File

@@ -0,0 +1,39 @@
---
id: edge-failures
area: edge
level: 3
title: "Artefact — The failures · kept in the record"
claim: "Three things did not go to plan — a feeder the animal destroyed · cameras postponed with the parts already bought · and a doorbell being rebuilt instead of declared finished."
confidence: in-progress
period: "2020–"
tech: [ESP32, Servo, PWM, Camera]
metrics: [{label: "crickets released per day by the feeder", value: 10}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [edge, failures, artefact]
---
# Artefact — The failures · kept in the record
**Status: in progress.** Two items are being rebuilt, one is postponed. The page is not finished because the work is not finished.
## What the artefact is
A short, unpolished register of what did not work, in the order it broke.
**The lizard feeder.** Servo motors on a 16-channel PWM driver, rubber-band spring-loaded boxes, an ESP32 — one box opened per day to release ten crickets for the bearded dragon it was built for. The lizard attacked the boxes and destroyed the mechanism. Version one is abandoned; it is being rebuilt with plastic containers.
**The garden cameras.** Postponed. The parts have arrived and are sitting in a box. No photographs and no claim until they are installed.
**The front door.** Built, then found to greet people who were not at the door. It is being rebuilt with a time-of-flight ranging sensor rather than being described as complete.
**The garden automation.** Partly manual. Some of it runs on schedule and some of it is a person. Both are true and the page says so.
## What it proves
That this is a working estate and not a rendering: things break, budgets get spent on parts that are not installed yet, and hardware has to be designed around an animal's behaviour. Production experience is measured in rebuilds as much as in launches.
## When it is delivered
Phase 7, with photographs of the rebuilt feeder and the installed cameras if they exist by then. If they do not, the entry stays as written — labelled rather than deleted.

View File

@@ -0,0 +1,35 @@
---
id: edge-front-door
area: edge
level: 3
title: "Artefact — Front door · vision · voice · and a rebuild"
claim: "A doorbell that sees a person · describes the image · speaks to the house and to the visitor · and is currently being rebuilt with a time-of-flight sensor."
confidence: in-progress
period: "2020–"
tech: [ESP32, ESP32-CAM, PIR, mmWave, VL53L0X, MiniMax, Piper, Whisper, Apprise]
metrics: [{label: "voice engines in use", value: 2}, {label: "presence sensing inputs", value: "PIR · mmWave · time-of-flight"}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [edge, doorbell, vision, voice, artefact]
---
# Artefact — Front door · vision · voice · and a rebuild
**Status: in progress.** The device was built and is being rebuilt. The page states this rather than describing it as finished.
## What the artefact is
A door unit with microphone, speaker, camera, button, PIR and mmWave presence. It detects a person, captures an image, logs it, has the image described, and announces to the house who is at the door before offering a spoken greeting; the visitor's reply is captured and played over the house speakers, with an alert pushed to a phone. Voice output ran on one provider and now runs on a cloned voice with a fallback to a local synthesiser.
## What it proves
Vision, voice and actuation in one device under real constraints — latency that a person at a door will tolerate, and a failure mode that must be polite.
## Limits stated on the page
The measurement it is being rebuilt around: presence detection with a time-of-flight ranging sensor, to stop the unit greeting people who are walking past rather than standing at the door. Until the rebuild is finished the honest label is `in-progress`.
## When it is delivered
Phase 7 for photos and the sensor change. Source: evidence notes section 1.

View File

@@ -0,0 +1,35 @@
---
id: edge-lora-sprinkler
area: edge
level: 3
title: "Artefact — LoRa irrigation under solar power"
claim: "A solar-powered controller that sleeps · wakes on radio · and drives a solenoid — with the first version polling over a message queue instead."
confidence: running
period: "2020–"
tech: [ESP32, LoRa, LiFePO, Solenoid, Home Assistant, RabbitMQ]
metrics: [{label: "major revisions", value: 2}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [edge, lora, solar, irrigation, artefact]
---
# Artefact — LoRa irrigation under solar power
**Status: stub.** The bill of materials and the wake-on-radio change land in Phase 7.
## What the artefact is
The build: solar panel, charge controller, LiFePO battery, ESP32, LoRa module, relay and solenoid valve. Version one woke on a schedule and polled through a message queue to learn whether it should run. Version two is better: a LoRa packet can wake the ESP32 from deep sleep and switch the valve directly, so on-time is short and the battery is not spent asking whether there is work.
## What it proves
Iteration on a real constraint. The first version worked; the second version was designed around the power cost of asking, which is the kind of decision you only make if you are watching the battery.
## Limits stated on the page
Watering is partly scheduled and partly a person. The routine lives in the automation layer and the voice assistant can trigger it, but the garden is not fully automatic and the page does not pretend otherwise.
## When it is delivered
Phase 7. Source: evidence notes section 1.

View File

@@ -0,0 +1,31 @@
---
id: edge-sensors
area: edge
level: 3
title: "Artefact — Sensors and actuation"
claim: "Movement · presence · weight · temperature · water and mechanical actuation — chosen per job and integrated into one automation layer."
confidence: running
period: "2020–"
tech: [ESP32, PIR, Radar, mmWave, Load cell, Servo, Solenoid, MQTT]
metrics: [{label: "automations", value: 20}, {label: "PWM channels on the feeder driver", value: 16}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [edge, sensors, actuation, artefact]
---
# Artefact — Sensors and actuation
**Status: stub.** A sensor and actuator table lands in Phase 7.
## What the artefact is
The parts list of the fleet in the way an engineer would want it: what is sensed, which device senses it, what is actuated, and how the decision reaches a physical action — a relay, a solenoid, a servo, a step-down converter switching 240V, a wheel that flicks food into a tank, an auger that dispenses it, a load cell that decides when to top up water.
## What it proves
Range across the awkward middle of embedded work: not just reading a sensor but choosing an actuator, sizing its driver, and making the whole loop recoverable when the network is absent.
## When it is delivered
Phase 7. Source: evidence notes section 1.

View File

@@ -0,0 +1,31 @@
---
id: edge-snapcast
area: edge
level: 3
title: "Artefact — Distributed audio · eight endpoints with ducking"
claim: "Eight synchronised endpoints with music ducked under every spoken announcement and restored afterwards — driven by Home Assistant and the voice assistant."
confidence: running
period: "2020–"
tech: [Snapcast, Mopidy, Spotify, Raspberry Pi, Home Assistant]
metrics: [{label: "audio endpoints", value: 8}, {label: "client hardware", value: "Pi 2W"}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [edge, audio, snapcast, artefact]
---
# Artefact — Distributed audio · eight endpoints with ducking
**Status: stub.** The topology diagram and a short recording of ducking land in Phase 7.
## What the artefact is
A Snapserver with eight Pi clients, Spotify and a local music server as sources, and one behaviour that matters more than the rest: when the house needs to speak, the music drops and the announcement plays everywhere, then the music returns to its previous level. Requests arrive by voice, so the same system that plays music is also the output for every automation and every answer.
## What it proves
Multi-room audio treated as infrastructure with a predictable interruption behaviour — and the difference between "the speakers work" and "the speakers can be interrupted safely". Mains-powered nodes, bandwidth-heavy work, and a state machine for volume: the opposite design constraints to the battery nodes.
## When it is delivered
Phase 7. Source: evidence notes section 1.

View File

@@ -0,0 +1,31 @@
---
id: edge-zigbee
area: edge
level: 3
title: "Artefact — Hand-built Zigbee routers and lighting"
claim: "Two routers built by hand to extend an indoor mesh · feeding nine lighting zones with presence sensing and voice control."
confidence: running
period: "2020–"
tech: [ESP32, Zigbee, Relay, PIR, Radar, Home Assistant]
metrics: [{label: "hand-built Zigbee routers", value: 2}, {label: "lighting zones", value: 9}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [edge, zigbee, lighting, artefact]
---
# Artefact — Hand-built Zigbee routers and lighting
**Status: stub.** The router build and a zone map land in Phase 7.
## What the artefact is
Two Zigbee routers on ESP32 hardware, built because the commercial mesh did not cover the house and a repeater would have been a black box. Behind them, nine lighting zones: custom presence sensors with PIR and radar for the bathroom and a child's bedroom, a central light controller with a readout and a potentiometer, and a three-button remote with a dial — click on, double click off, dial for level.
## What it proves
Mesh networking understood at the level where you can add a node instead of buying one, and lighting built for how a house is used rather than for a product category.
## When it is delivered
Phase 7. Source: evidence notes section 1.

View File

@@ -0,0 +1,63 @@
---
id: history-engagements
area: history
level: 2
title: "Fifteen years of delivery for agencies and their clients"
claim: "Twenty-two agencies and twenty-three brands · national campaigns · bank and broadcaster platforms · betting front-ends · and a habit of being called in to rescue work someone else could not finish."
confidence: historic
period: "1999–2014"
tech: [ActionScript, PHP, JavaScript, MySQL, XML, SOAP, React, Python]
metrics: [{label: "agencies on the client wall", value: 22}, {label: "brands delivered for", value: 23}, {label: "code exhibits kept", value: 6}]
artefacts: [{type: doc, label: "The client wall", url: "content/history/l3/history-client-wall.md"}, {type: doc, label: "Six code exhibits across five eras", url: "content/history/l3/history-code-exhibits.md"}]
public: true
last_verified: 2026-10-26
tags: [history, agencies, brands, exhibits]
---
# Fifteen years of delivery for agencies and their clients
**Capability statement.** Range under deadline. The work in this period ran from car classifieds and online betting to a bank's pitch piece, a broadcaster's on-demand banners and a publisher's education platform — usually with a date attached that could not move.
## The eras, briefly
**1999–2002 — the start.** Web design and development for a high-volume car marketplace, then freelance work building sites, applications, interfaces and database back ends for small agencies.
**2002–2005 — London, full time.** Online gaming: lead front-end developer on a synchronous multi-game platform handling a thousand simultaneous clients, integrating against Java back ends and XML sockets. Then the emerging interactive advertising industry, where a creative front end with a technical spine was scarce.
**2006–2009 — the agency run.** The bulk of the client wall: bank pitch work delivered in weeks, broadcaster promotions, a global oil company's re-brand site, premium drinks campaigns, a news organisation's media presentation layer, a publisher's education platform, and a global computer-aided design vendor's localised site. Several of these were rescues — brought in on a short deadline to rewrite work another team could not land.
**2009–2012 — product and campaign.** A standalone encrypted presentation builder with its own media library and remote update path. A national beer campaign with video submissions, cross-brand promotion, in-store mechanics and authorisation over SOAP. Interactive launches with 3D content in the browser.
**2012–2014 — small products for small business.** Online book building, a reactive design system, and art applications for a motion-sensing controller, transitioned toward mobile.
**2014–2020 — the workshop years.** The site builder and the signage product (see [Products](../products/products-business.md)), which is where the work moved from delivering campaigns for other people's brands to owning software and supporting customers.
## Languages across the eras
The point of the exhibit set is that the tooling changed repeatedly and the delivery discipline did not: ActionScript 3 with class-based architecture, hand-written JavaScript engines, PHP media pipelines, TypeScript with typed service layers and a relational schema, embedded C++ for firmware, and now declarative infrastructure as its own kind of code.
## Evidence
| Claim | Evidence | Artefact |
|---|---|---|
| Twenty-two agencies and twenty-three brands | The client wall, carried from the résumé and the recovered site content | [Client wall](l3/history-client-wall.md) |
| Work across six technology eras | Six exhibits from OO ActionScript to a hand-written Nix package | [Code exhibits](l3/history-code-exhibits.md) |
| Rescue work under immovable deadlines | Multiple engagements recorded as rewrites of stalled projects | [Client wall](l3/history-client-wall.md) |
| Sponsorship of the later product era | 2014–2020 product work described in the résumé | [Products](../products/products-business.md) |
## Trade-offs and limits
- **Historic work is labelled historic.** Nothing in this section is presented as current capability; where a technique is still used today, the page links to where it is used.
- **One engagement has no screenshot.** The national archive work was an internal tool. It is described and not illustrated, because there is nothing legitimately publishable to show.
- **No client has been named beyond a brand.** Brands are named because they were public campaigns. Individuals, internal contacts and unpublished commercial detail are excluded.
- **The exhibits are excerpts.** Around forty lines each, chosen to show judgement — structure, error handling, an exit plan — not to demonstrate volume.
## Drill-downs
- [The client wall](l3/history-client-wall.md) — level 3
- [Six code exhibits across five eras](l3/history-code-exhibits.md) — level 3
- [Products — the commercial era that followed](../products/products-business.md) — level 2
## Period and confidence
`period: 1999–2014`. Confidence is `historic`: this is work that is finished, and it is presented in the past tense throughout.

View File

@@ -0,0 +1,25 @@
---
id: history-overview
area: history
level: 1
title: "History"
claim: "Delivered interactive work for twenty-two agencies and twenty-three brands between 1999 and 2014 — the commercial apprenticeship that produced the range."
confidence: historic
period: "1999–2014"
tech: [ActionScript, PHP, JavaScript, MySQL, XML, React, Python, Go, HTMX]
metrics: [{label: "agencies on the client wall", value: 22}, {label: "brands delivered for", value: 23}, {label: "code exhibits kept", value: 6}]
artefacts: [{type: doc, label: "The client wall", url: "content/history/l3/history-client-wall.md"}, {type: doc, label: "Six code exhibits across five eras", url: "content/history/l3/history-code-exhibits.md"}]
public: true
last_verified: 2026-10-26
tags: [history, agencies, brands, career]
---
# History
**Claim.** Fifteen years of agency and product work — national campaigns, bank and broadcaster platforms, betting front-ends, and a lot of rescue missions — for twenty-two agencies and twenty-three brands.
**Status.** Historic. This is the 1999–2014 era, presented as what it was: the commercial apprenticeship that produced the range.
**What it is not.** It is not a Flash page. The languages changed six times across this period — ActionScript, PHP, JavaScript, React, Python, and now Go with HTMX — and the exhibits are chosen to show that rather than to show a decade.
**Start here.** [The engagement record](history-engagements.md) explains the eras, the work and what each exhibit demonstrates.

View File

@@ -0,0 +1,41 @@
---
id: history-client-wall
area: history
level: 3
title: "Artefact — The client wall"
claim: "Twenty-two agencies and twenty-three brands — the delivery record carried from the résumé rather than reconstructed from memory."
confidence: historic
period: "1999–2014"
tech: [ActionScript, PHP, JavaScript, MySQL, XML]
metrics: [{label: "agencies on the client wall", value: 22}, {label: "brands on the client wall", value: 23}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [history, clients, artefact]
---
# Artefact — The client wall
**Status: stub for the visual treatment.** The names are final; the layout, logo set and typography land in Phase 7 (the logo set is explicitly a Phase 7 decision).
## 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
## 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
## Corrections carried into this list
- **Diageo**, not the "Diajio" spelling that appeared in the recovered site content.
- **Baileys Irish Cream**, stated in full and spelled correctly.
- The **BBC National Archive** engagement was an internal tool. It is described, and it is not illustrated with a screenshot, because no legitimately publishable image exists.
## Count discrepancy — recorded, not smoothed over
The summary material said twenty-three agencies. The recovered, verified list holds **twenty-two** names, and the count published above is the count of the list. If a name is missing, the number moves to twenty-three and this note is deleted; until then the smaller, checkable number is the one on the page.
## When it is delivered
Phase 7 for the visual treatment. Names and one-line descriptions are approved as recovered and corrected.

View File

@@ -0,0 +1,40 @@
---
id: history-code-exhibits
area: history
level: 3
title: "Artefact — Six code exhibits across five eras"
claim: "Six short excerpts spanning object-oriented ActionScript · a hand-written JavaScript animation engine · a PHP media pipeline · typed TypeScript · embedded C++ · and declarative Nix."
confidence: historic
period: "2006–2026"
tech: [ActionScript, JavaScript, PHP, TypeScript, C++, Nix]
metrics: [{label: "code exhibits", value: 6}, {label: "technology eras spanned", value: 5}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [history, code, exhibits, artefact]
---
# Artefact — Six code exhibits across five eras
**Status: stub.** The excerpts are selected from source that exists on disk; each one lands in Phase 7 with two sentences on why it is good code.
## The six
| # | Era | Source | What the excerpt shows |
|---|---|---|---|
| 1 | Object-oriented ActionScript | the brief-case builder and the national beer campaign | class design, imports, encapsulation |
| 2 | Hand-written JavaScript engine | the text-effects engine from the site builder (about 2 200 lines) | easing and a timeline core |
| 3 | PHP media pipeline | the media encoder from the site builder (about 3 400 lines) | one encode operation and the FFmpeg piping around it |
| 4 | TypeScript | the vehicle-tracking application's schema and typed service layer | types, interfaces, a router |
| 5 | Embedded C++ | the Wi-Fi manager interface and implementation | interface versus implementation in firmware |
| 6 | Declarative infrastructure | the 25-line Nix package definition | pinned version, verified hash, written exit condition |
Total is roughly 225 lines across twenty years, which is the point: each excerpt is chosen to show judgement rather than volume.
## What it proves
That the stack changed six times and the discipline did not. Exhibit 6 is the least common of the six and the most current — a short, correct, self-documenting derivation with an exit plan is a stronger infrastructure signal than a long configuration file.
## When it is delivered
Phase 7. Source paths are recorded in the planning notes; nothing is published from a path that has not been read at capture time.

View File

@@ -0,0 +1,35 @@
---
id: platform-backups
area: platform
level: 3
title: "Artefact — Backups and restore runbook"
claim: "Three tiers — local snapshots · a copy on another machine · an offsite copy — with retention stated in the job and a restore runbook kept beside them."
confidence: running
period: "2020–"
tech: [Borg, Kopia, Backrest, AWS S3, systemd, NFS]
metrics: [{label: "backup tiers", value: 3}, {label: "retention windows in the primary job", value: 3}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [platform, backups, restore, artefact]
---
# Artefact — Backups and restore runbook
**Status: stub.** Redacted job definitions and the restore runbook land in Phase 7. Passphrase locations and remote paths are removed before publication.
## What the artefact is
The backup design: Borg jobs on the two desktop hosts to local storage with a network copy, Kopia and Backrest on the edge host to network storage, an offsite copy in object storage, and retention expressed in the job (seven daily, four weekly, three monthly, plus annual on the edge). Beside it, the restore runbook and the list of what is deliberately not covered.
## What it proves
That failure was designed for: three tiers cover disk loss, machine loss and site loss, and the restore path is written down rather than recovered from memory at the worst possible moment.
## Limits stated on the page
Not everything is covered, and the page says so — one storage host is itself a single point of failure for some repositories, and the file server has no backup of its own. Those are known gaps with owners, not oversights to be hidden.
## When it is delivered
Phase 7. Source: the infrastructure reference and the backup system notes, read 2026-10-26.

View File

@@ -0,0 +1,31 @@
---
id: platform-containers
area: platform
level: 3
title: "Artefact — Container inventory"
claim: "Fifty or more containers across the estate — each with its own port · its own folder · and a memory cap equal to its swap cap."
confidence: running
period: "2020–"
tech: [Docker, Compose, Caddy, Proxmox]
metrics: [{label: "containers in the estate", value: 50}, {label: "memory cap rule", value: "mem_limit equals memswap_limit"}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [platform, docker, artefact]
---
# Artefact — Container inventory
**Status: stub.** A digest of the inventory — grouped by purpose, with addresses removed — lands in Phase 7.
## What the artefact is
The inventory generated from the hosts, grouped by purpose: media and file services, home automation, the AI services, the public edge, and the build tooling. For each service the same three facts are recorded — port, configuration folder, memory cap.
## What it proves
That the estate is inventoried rather than accumulated, and that the resource rules are applied per service instead of being a policy on a wiki page.
## When it is delivered
Phase 7, generated from the live hosts. Counts follow the approved metric set; the exact number is regenerated at capture time.

View File

@@ -0,0 +1,31 @@
---
id: platform-network
area: platform
level: 3
title: "Artefact — Network layout and remote access"
claim: "A private mesh and SSH everywhere · services bound to loopback · and one public listener at the edge terminating TLS for the sites that are allowed to be seen."
confidence: running
period: "2020–"
tech: [Tailscale, SSH, Caddy, DNS, Termux]
metrics: [{label: "public listeners per service", value: 1}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [platform, networking, security, artefact]
---
# Artefact — Network layout and remote access
**Status: stub.** The topology diagram lands in Phase 7 with addresses and hostnames redacted.
## What the artefact is
The layout: hosts on a private mesh for remote access, SSH on every machine, a phone used as a terminal, and an edge host that terminates TLS and proxies to loopback-bound services. New services bind to loopback by default; making something public is an explicit edge change, not a side effect of starting a container.
## What it proves
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.
## When it is delivered
Phase 7. Internal addressing, hostnames and public DNS names are excluded from publication by the redaction pass.

View File

@@ -0,0 +1,35 @@
---
id: platform-nixos
area: platform
level: 3
title: "Artefact — Declarative multi-host configuration"
claim: "Machines are described rather than assembled — a shared NixOS base · a replicated desktop and terminal · and Proxmox underneath the virtualised side."
confidence: running
period: "2020–"
tech: [NixOS, Niri, Proxmox, Neovim, Zsh, Ghostty]
metrics: [{label: "hosts on the declarative base", value: 4}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [platform, nixos, artefact]
---
# Artefact — Declarative multi-host configuration
**Status: stub.** A redacted excerpt of the configuration tree lands in Phase 7. No hostnames, addresses or keys are published.
## What the artefact is
The shape of the configuration: one shared base, per-host modules, and the parts that make the desktop and the terminal identical everywhere — window manager, editor, shell and the plugins that carry the work. Proxmox hosts the virtualised side, including the file server and the home automation appliance.
## What it proves
That a machine can be rebuilt from a description, and that the same environment follows the person between machines instead of being re-created by memory.
## Limits stated on the page
The terminal-first setup is deliberate: a heavier desktop was downgraded to the terminal on one machine for memory headroom. A phone-as-server experiment is on hold, not shipped, and is described as on hold.
## When it is delivered
Phase 7. Source facts: evidence notes section 3.

View File

@@ -0,0 +1,31 @@
---
id: platform-signage
area: platform
level: 3
title: "Artefact — Signage infrastructure · screens as a managed fleet"
claim: "HDMI-CEC control of power · rotation · mirroring and multi-screen layout — so a display fleet is driven by the platform rather than by a remote control."
confidence: delivered
period: "2014–"
tech: [HDMI-CEC, Raspberry Pi, Python, JavaScript, FFmpeg]
metrics: [{label: "commercial deployments evidenced", value: "salon · financial services business"}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [platform, signage, hdmi-cec, artefact]
---
# Artefact — Signage infrastructure · screens as a managed fleet
**Status: stub.** The CEC command set and a layout diagram land in Phase 7.
## What the artefact is
The platform side of the signage product: the codes and control path that switch screens on and off, rotate and mirror output, and lay out multi-screen displays, including synchronised playback across screens in one site.
## What it proves
Device-level control combined with fleet management. This is the same skill the edge pages show on small devices, applied to physical displays that other people depend on.
## Confidence
`delivered` — this ran in commercial deployments. The product's business status is stated separately on the Products pages, where the honest position is that the software is being rebuilt and three businesses still run it.

View File

@@ -0,0 +1,31 @@
---
id: platform-tuios-package
area: platform
level: 3
title: "Artefact — A hand-written Nix package with an exit plan"
claim: "Twenty-five lines of Nix that pin a tool at 0.10.1 over nixpkgs' 0.7.0 — with a verified hash · a written reason and a written condition for deleting the file."
confidence: running
period: "2026–"
tech: [Nix, NixOS]
metrics: [{label: "pinned version", value: "0.10.1"}, {label: "nixpkgs version it overrides", value: "0.7.0"}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [platform, nix, packaging, artefact]
---
# Artefact — A hand-written Nix package with an exit plan
**Status: stub.** The whole file is short enough to publish verbatim; it lands in Phase 7.
## What the artefact is
A package definition for the agent operations console: `fetchurl` against a released artifact with a verified hash, a documented reason for overriding nixpkgs, an `installPhase` that stages the binary, and a comment stating the condition under which the file should be deleted.
## What it proves
More than knowing Nix exists. The signals are that someone noticed a version gap, fixed it locally instead of waiting, pinned it so the fleet is reproducible, and wrote down when to remove their own patch. Knowing when to delete your own override is the senior part.
## When it is delivered
Phase 7. The file is in the infrastructure repository; source fact: content spine section 4.2.

View File

@@ -0,0 +1,66 @@
---
id: platform-estate
area: platform
level: 2
title: "A small estate run to a documented standard"
claim: "Fifty or more containers on a four-host estate, all on declarative configuration — with packaging discipline · encrypted backups and a restore runbook · loopback-bound services · and one public listener."
confidence: running
period: "2020–"
tech: [NixOS, Proxmox, Docker, Caddy, Tailscale, Borg, Kopia, AWS S3, HDMI-CEC]
metrics: [{label: "containers in the estate", value: 50}, {label: "hosts", value: 4}, {label: "backup tiers", value: "Borg · Kopia · AWS"}, {label: "public listeners", value: 1}]
artefacts: [{type: doc, label: "Declarative multi-host configuration", url: "content/platform/l3/platform-nixos.md"}, {type: doc, label: "A hand-written Nix package with an exit plan", url: "content/platform/l3/platform-tuios-package.md"}, {type: doc, label: "Container inventory", url: "content/platform/l3/platform-containers.md"}, {type: doc, label: "Backups and restore runbook", url: "content/platform/l3/platform-backups.md"}, {type: doc, label: "Network layout and remote access", url: "content/platform/l3/platform-network.md"}, {type: doc, label: "Signage infrastructure — screens as a managed fleet", url: "content/platform/l3/platform-signage.md"}]
public: true
last_verified: 2026-10-26
tags: [platform, devops, nixos, docker, backups, networking]
---
# A small estate run to a documented standard
**Capability statement.** Four hosts, fifty or more containers, configuration in version control instead of in a shell history, and one place where traffic enters. The estate is deliberately small because it is run rather than demonstrated.
## What is actually run
**Declarative configuration.** NixOS is the base on the machines that matter, with the desktop and the terminal replicated across them so an environment can be rebuilt from a description. Proxmox runs the virtualised side — Ubuntu file server and a Home Assistant appliance alongside the desktop hosts. The point of the arrangement is repeatability: a machine is a configuration, not a pet.
**Packaging.** When a tool is missing or stale in nixpkgs, it gets a derivation rather than a manual install. The TUIOS package pins 0.10.1 over nixpkgs' 0.7.0 with `fetchurl` and a verified hash, documents why the override exists, and states the condition for deleting it. A patch with an exit plan is maintenance; a patch without one is debt.
**Container estate.** Fifty or more containers run across the hosts — the file server and media services, the automation layer, the AI services, traffic for public ingress, and the tools used to build all of it. Each new service is given its own port, its own folder and a memory cap equal to its swap cap, so it cannot take the host's swap.
**Networking.** Remote access is a private mesh rather than an exposed SSH port: Tailscale across the machines, SSH on every host, and a phone used as a terminal. Services bind to loopback; Caddy is the only public listener, terminating TLS at the edge host and proxying the sites that are allowed to be seen.
**Storage and backups.** Three tiers, each covering a different failure: local snapshots on the machine, a copy on another machine over the network, and an offsite copy in object storage. Retention is stated in the job (daily, weekly, monthly, annual) and a restore runbook sits next to the jobs, because a backup nobody has restored from is an assumption.
**Observability and operations.** Resource use is watched at the host level, because the runtime host is memory-bound and a new service can destabilise the ones people rely on. The agent fleet is observed as a fleet through TUIOS — sessions, state and approvals in one console — rather than by tailing logs on four machines.
**Signage infrastructure.** The display work is treated as a fleet to be managed: HDMI-CEC control for power, rotation, mirroring and multi-screen layout, so screens are driven by the platform rather than by a person with a remote control.
## Evidence
| Claim | Evidence | Artefact |
|---|---|---|
| Configuration is declarative and replicated | NixOS hosts with shared configuration; Proxmox for the virtualised side | [Declarative configuration](l3/platform-nixos.md) |
| Tooling is packaged rather than hand-installed | `pkgs/tuios.nix` pinning 0.10.1 with a documented removal condition | [Nix package](l3/platform-tuios-package.md) |
| Fifty or more containers are operated | Inventory by host and by purpose | [Container inventory](l3/platform-containers.md) |
| Backups are three-tier with a restore runbook | Borg on two hosts, Kopia and Backrest on the edge host, offsite object storage | [Backups](l3/platform-backups.md) |
| One public listener | Loopback-bound services behind Caddy at the edge | [Network](l3/platform-network.md) |
| Screens are managed as a fleet | HDMI-CEC control of power, rotation and layout | [Signage infrastructure](l3/platform-signage.md) |
## Trade-offs and limits
- **Memory over hardware.** The runtime host runs 15 GiB with little free and swap in use. The response was caps, fewer and smaller services, and moving heavy work to the gateway rather than buying another machine. Every new service is sized before it is deployed, and the ceiling for this site is one gigabyte and four containers.
- **Self-hosting is a choice with a cost.** Monitoring, backups, ingress and the AI gateway are all operated here, which means the failure modes are also operated here. The mitigation is documentation and measurement, not optimism.
- **Rejected on purpose.** A long list of tools was evaluated and refused — Airflow for RAM, self-hosted Langfuse for six containers it could not afford, LiteLLM because it would duplicate the existing gateway, and a pile of documentation platforms. "Alternatives considered" is kept because the reasoning is the senior artifact.
- **Not everything is automated.** Some operations are still a documented command run by a person. The runbook exists precisely because the automations do not cover everything.
## Drill-downs
- [Declarative multi-host configuration](l3/platform-nixos.md) — level 3
- [A hand-written Nix package with an exit plan](l3/platform-tuios-package.md) — level 3
- [Container inventory](l3/platform-containers.md) — level 3
- [Backups and restore runbook](l3/platform-backups.md) — level 3
- [Network layout and remote access](l3/platform-network.md) — level 3
- [Signage infrastructure — screens as a managed fleet](l3/platform-signage.md) — level 3
## Period and confidence
`period: 2020–`, anchored on the homelab year in the evidence notes. Confidence is `running`: this is the current estate, verified 2026-10-26.

View File

@@ -0,0 +1,25 @@
---
id: platform-overview
area: platform
level: 1
title: "Platform & Infrastructure"
claim: "Runs a multi-machine NixOS and Proxmox estate — fifty-plus containers · declarative configuration · encrypted backups with a written restore runbook · and public ingress — sized to the hardware rather than the budget."
confidence: running
period: "2020–"
tech: [NixOS, Proxmox, Docker, Caddy, Tailscale, Borg, Kopia, AWS S3]
metrics: [{label: "containers in the estate", value: 50}, {label: "hosts", value: 4}, {label: "backup tiers", value: "Borg · Kopia · AWS"}]
artefacts: [{type: doc, label: "Declarative multi-host configuration", url: "content/platform/l3/platform-nixos.md"}, {type: doc, label: "Backups and restore runbook", url: "content/platform/l3/platform-backups.md"}, {type: doc, label: "A hand-written Nix package with an exit plan", url: "content/platform/l3/platform-tuios-package.md"}]
public: true
last_verified: 2026-10-26
tags: [platform, devops, nixos, docker, backups]
---
# Platform & Infrastructure
**Claim.** Fifty or more containers on a four-host estate, configuration declared rather than hand-applied, encrypted backups on three machines with a restore runbook beside them, and one public listener at the edge.
**Status.** Running. Figures trace to the approved metric set and the infrastructure reference; verified 2026-10-26.
**The constraint that shaped it.** The runtime host is memory-bound rather than CPU-bound. Every service in this estate carries a memory cap and is bound to loopback, which is why the platform reads as engineering under constraint instead of hardware shopping.
**Start here.** [The capability page](platform-estate.md) covers what runs, where, and what was rejected.

View File

@@ -0,0 +1,35 @@
---
id: products-signage
area: products
level: 3
title: "Artefact — Commercial signage on commodity hardware"
claim: "A digital signage platform built as market disruption — any HDMI screen · a small player · fleet control of power · rotation · layout and playback · sold to twelve businesses over time."
confidence: delivered
period: "2014–"
tech: [Raspberry Pi, ESP32, HDMI-CEC, JavaScript, three.js, FFmpeg, Python]
metrics: [{label: "businesses currently running the product", value: 3}, {label: "adoptions over time", value: 12}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [products, signage, artefact]
---
# Artefact — Commercial signage on commodity hardware
**Status: delivered, with a rebuild in progress.** The product shipped and customers still run it. The rebuild on the current stack is not finished and is labelled as unfinished everywhere it is mentioned.
## What the artefact is
The product in use: a player on commodity hardware — a small Raspberry Pi or a microcontroller class device — driving any HDMI screen, with fleet control from the platform: switch screens on and off, rotate, mirror, lay out multiple screens, coordinate boards, and synchronise playback across a site. Content is built by the customer from complex animation down to a simple image rotation, and the system adapts to the board it finds rather than demanding a specification.
## What it proves
The commercial read: a market was overpriced, the substitute was built on commodity hardware, and it was sold and operated for years. Twelve adoptions over time, three businesses still running. That is a product, not a project.
## Limits stated on the page
Twelve is cumulative adoption and three is the current count — the distinction is kept because inflating it would be the easiest lie on this site. The rebuild is in progress. No revenue figures or customer names beyond type of business are published.
## When it is delivered
Phase 7 for screenshots and a demonstration recording, captured from the running product. The one-liners for reference customers are approved for the salon and the financial services business only.

View File

@@ -0,0 +1,35 @@
---
id: products-websitebuilder
area: products
level: 3
title: "Artefact — The site builder behind the signage"
claim: "A templated site builder and CMS with media editors — drawing · image · video · text animation · 3D import — that also produced the marketing sites and the social output."
confidence: delivered
period: "2014–"
tech: [PHP, JavaScript, three.js, FFmpeg, ImageMagick, MySQL]
metrics: [{label: "media editors in the product", value: "drawing · image · video · text animation · transitions · 3D import"}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [products, cms, media, artefact]
---
# Artefact — The site builder behind the signage
**Status: delivered, retained.** Retired as a general-purpose product; kept as the content engine for the signage.
## What the artefact is
The templated builder: pick a template, build the site, publish it — with editors for drawing, images, video and text animation, video effects and transitions, 3D object import and text extrusion, plus document and office-format handling. Image animation is composed in JavaScript and HTML and rendered on Raspberry Pi hardware through Python. Around the site itself sat the commercial scaffolding a small business actually needs: shop, invoicing, room and event management, presentations, document management, client records and bulk email and SMS.
## What it proves
Two things at once — depth in media pipelines (FFmpeg, ImageMagick, PDF and office formats, three.js) and the discipline of shipping a general product that strangers could operate without help.
## Limits stated on the page
The surface was too large for one person to support indefinitely. The plan recorded here is to strip it back to signage plus the content engine and stop selling the rest, which is a decision about focus rather than a claim of failure.
## When it is delivered
Phase 7 for a screen recording of the editors. Source: résumé (2014–2020 freelance) and evidence notes section 3.

View File

@@ -0,0 +1,35 @@
---
id: products-where-woof
area: products
level: 3
title: "Artefact — Where-Woof · deployed and pre-market"
claim: "A QR and RFID tag system for lost property that is deployed and in use — and has not gone to market."
confidence: running
period: "2020–"
tech: [QR, RFID, PHP, JavaScript]
metrics: [{label: "market launch", value: 0}]
artefacts: []
public: true
last_verified: 2026-10-26
tags: [products, where-woof, artefact]
---
# Artefact — Where-Woof · deployed and pre-market
**Status: running, pre-market.** This is the exact wording the site uses, deliberately: deployed is true, commercial is not.
## What the artefact is
A tag system for lost property. A tag carries a QR code and an RFID identity; whoever finds the item resolves the tag and the owner is contacted. The system is running and being deployed now.
## What it proves
That a product can be taken into the field before it is monetised, and that the honest label for that state exists. The distinction matters on a CV because the alternative — calling a pre-launch product commercial — is the kind of claim a technical interviewer unpicks in one question.
## Limits stated on the page
No customers, no revenue and no launch date are claimed. The metric recorded here is a zero on purpose.
## When it is delivered
The tag and resolve-flow photographs land in Phase 7 if Sam chooses to publish them. Product name and description are approved; commercial framing is not.

View File

@@ -0,0 +1,67 @@
---
id: products-business
area: products
level: 2
title: "Software sold and operated — with the status stated"
claim: "Three product lines from one workshop — signage on commodity hardware · the site builder that produced the marketing · and a lost-property tag system — with three businesses still running the signage today."
confidence: running
period: "2014–"
tech: [PHP, JavaScript, three.js, FFmpeg, ImageMagick, HDMI-CEC, Raspberry Pi, ESP32, QR, RFID]
metrics: [{label: "businesses currently running the product", value: 3}, {label: "adoptions over time", value: 12}, {label: "product lines", value: 3}]
artefacts: [{type: doc, label: "Commercial signage on commodity hardware", url: "content/products/l3/products-signage.md"}, {type: doc, label: "The site builder behind it", url: "content/products/l3/products-websitebuilder.md"}, {type: doc, label: "Where-Woof — deployed and pre-market", url: "content/products/l3/products-where-woof.md"}, {type: doc, label: "Signage infrastructure — screens as a managed fleet", url: "content/platform/l3/platform-signage.md"}]
public: true
last_verified: 2026-10-26
tags: [products, business, signage, where-woof, positioning]
---
# Software sold and operated — with the status stated
**Capability statement.** Building software for a client and building software as a product are different jobs. This page is the second one: a market that was overpriced, a substitute built on hardware that costs a fraction of the incumbent systems, customers who adopted it, and the operational cost of keeping it running.
## The commercial story
The digital signage market sells walls: thousands of dollars of hardware with fans, cooling and glass, locked to monopoly software. The product was built as a deliberate inversion of that — a system that runs on a small Raspberry Pi or a microcontroller, drives any HDMI screen including an ordinary monitor, and adapts to the board rather than requiring the board to adapt to it.
It manages screens as a fleet: power on and off, rotation, mirroring, multi-screen layout, coordinated boards, and synchronised playback across screens in one site or across sites. Users build complex animations or simple image rotations, and the system presents whatever suits the display it finds.
Demonstrations were rolled out in a hair salon and a financial services business. Around twelve businesses adopted it over time and three still run it today. That number is deliberately specific: twelve is cumulative adoption, three is current, and the difference is the honest measure of a product that was sold but not scaled.
The same workshop produced the CMS behind the marketing — a templated site builder with editors for drawing, image, video and text animation, video effects and transitions, 3D object import and text extrusion — built on JavaScript and three.js with FFmpeg, ImageMagick and PHP tools for documents, and rendering image animation on Raspberry Pi hardware.
The third product is different in kind: Where-Woof is a QR and RFID tag system for lost property. It is deployed and being deployed now. It has **not** gone to market, and this page will not call it commercial until it has.
## Evidence
| Claim | Evidence | Artefact |
|---|---|---|
| Signage replaced thousand-dollar wall systems with commodity hardware | Any HDMI screen · Raspberry Pi or ESP32 class player · fleet control | [Signage product](l3/products-signage.md) |
| Twelve adoptions over time, three businesses running now | Adoption history and current customers | [Signage product](l3/products-signage.md) |
| A templated builder produced the marketing sites | Editors for drawing · video · image and animation; FFmpeg and ImageMagick pipeline | [WebsiteBuilder](l3/products-websitebuilder.md) |
| Where-Woof is deployed and pre-market | Device and tag system in use; no market launch | [Where-Woof](l3/products-where-woof.md) |
| Screens are managed as a fleet | HDMI-CEC control of power · rotation · layout | [Signage infrastructure](../platform/l3/platform-signage.md) |
## Product status — stated plainly
| Product | What it is | Status | Next |
|---|---|---|---|
| Display screen media (Audas Media) | Digital signage on commodity hardware with multi-screen fleet control | Delivered · three businesses running · being rebuilt with AI | Strip back to signage only and rebuild on the current stack |
| WebsiteBuilder | Templated site builder and CMS with media editors and social output | Delivered · retained | Retired as a general product; kept as the content engine |
| Where-Woof | QR and RFID tags for lost property | Deployed · pre-market | Take to market — not done yet |
## Trade-offs and limits
- **Scope discipline came late.** The builder carried a shop, invoicing, room management, calendars, presentations, documents, bulk messaging and client management. Product surface grew faster than a one-person support model could carry, which is why the plan now is to keep the signage and the content engine and retire the rest.
- **The rebuild is unfinished.** "Rebuilding with AI" is a statement of work in progress, not a launch. It is labelled as such on every page that mentions it.
- **One product is pre-revenue by design.** Where-Woof is deployed before it is sold. Describing it as commercial would be a lie that a careful reader would find in a minute.
- **Support is the hidden cost.** Customers running the product for years is the strongest evidence of value and the strongest argument for keeping the surface small.
## Drill-downs
- [Commercial signage on commodity hardware](l3/products-signage.md) — level 3
- [The site builder behind it](l3/products-websitebuilder.md) — level 3
- [Where-Woof — deployed and pre-market](l3/products-where-woof.md) — level 3
- [Signage infrastructure — screens as a managed fleet](../platform/l3/platform-signage.md) — level 3
## Period and confidence
`period: 2014–`, anchored on the first freelance product work in the résumé. Confidence is `running`: the products exist and customers still use one of them. Individual products carry their own status, and the two caveats — a rebuild in progress and a launch not yet made — are in the table above rather than in a footnote.

View File

@@ -0,0 +1,25 @@
---
id: products-overview
area: products
level: 1
title: "Products"
claim: "Identified an overpriced market and shipped a cheaper substitute on commodity hardware — twelve adoptions over time and three businesses still running it."
confidence: running
period: "2014–"
tech: [PHP, JavaScript, three.js, FFmpeg, ImageMagick, HDMI-CEC, Raspberry Pi, ESP32]
metrics: [{label: "businesses currently running the product", value: 3}, {label: "adoptions over time", value: 12}, {label: "product lines", value: 3}]
artefacts: [{type: doc, label: "Commercial signage on commodity hardware", url: "content/products/l3/products-signage.md"}, {type: doc, label: "The site builder behind it", url: "content/products/l3/products-websitebuilder.md"}, {type: doc, label: "Where-Woof — deployed and pre-market", url: "content/products/l3/products-where-woof.md"}]
public: true
last_verified: 2026-10-26
tags: [products, signage, business, where-woof]
---
# Products
**Claim.** A digital signage product built as deliberate disruption to thousand-dollar wall systems — three businesses still run it — plus the site builder that produced the marketing sites and a lost-property tag system that is deployed but not yet sold.
**Status.** Running. The counts are the corrected ones: three businesses active now, twelve adoptions over time, and Where-Woof deployed without a market launch.
**Why it is on an engineering CV.** Selling, deploying and still supporting software for other businesses is a different discipline from writing it. Two of the three products carry honest caveats that are stated rather than hidden: the signage platform is being rebuilt, and Where-Woof has not gone to market.
**Start here.** [The commercial story](products-business.md) covers what each product is, what state it is in, and what it cost to run.

29
deploy/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
# Runtime image for the Mode A site (docs/phase-2-static-site.md §2.1).
#
# The image contains no toolchain: `make build dist` on the build host produces
# bin/resume-server and dist/, and the image just carries them. That keeps the
# runtime offline and small, and it means the deployed bytes are the bytes that
# were built and tested, not a second compile.
#
# Build context is the repository root:
#
# make build dist
# docker build -f deploy/Dockerfile -t ai-resume:local .
#
# Phase 3 (§3.1) owns the release directory, the compose invocation and the
# rollback; this file only describes the process that runs.
FROM gcr.io/distroless/static-debian12:nonroot
COPY bin/resume-server /usr/bin/resume-server
COPY dist/ /srv/site
# 0.0.0.0 is inside the container's own network namespace only. The host
# publishes the port on 127.0.0.1 (see deploy/compose.yml), which is what keeps
# Caddy the only public listener.
ENV RESUME_ADDR=0.0.0.0:8096 \
RESUME_DIST=/srv/site
EXPOSE 8096
USER nonroot:nonroot
ENTRYPOINT ["/usr/bin/resume-server"]

69
deploy/compose.yml Normal file
View File

@@ -0,0 +1,69 @@
# Container shape for the Mode A site (docs/phase-2-static-site.md §2.1).
#
# Values are fixed by the specification and must not drift:
# mem_limit == memswap_limit == 120m so the app cannot eat the host's swap
# ports published on 127.0.0.1 only Caddy on .35 is the only public listener
# logging capped one bad day cannot fill the disk
#
# Run it (build the artefacts first — Phase 3's deploy.sh does both):
#
# make build dist
# docker compose -f deploy/compose.yml up -d
#
# `curl http://127.0.0.1:8096/healthz` must answer with static ready.
# The compose project name is deliberately not `ai-resume`: /home/sam/deployment/
# ai-resume on .13 already runs a compose project by that name (backend + db on
# the ai-mesh network). Sharing the name would make this site's containers show
# up as orphans in that project and vice versa, so anything running `compose
# down --remove-orphans` under either project could stop the other. The image
# tag stays ai-resume:local (Phase 3 fixes it); only the project namespace is
# distinct.
name: ai-resume-site
services:
resume:
image: ai-resume:local
build:
context: ..
dockerfile: deploy/Dockerfile
restart: unless-stopped
mem_limit: 120m
memswap_limit: 120m
# The app listens on 0.0.0.0 inside the container namespace; the host-side
# binding is loopback, so nothing is reachable from the LAN except through
# docker-proxy on 127.0.0.1:8096 — which is what Caddy proxies to.
ports:
- "127.0.0.1:8096:8096"
read_only: true
security_opt:
- no-new-privileges:true
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
healthcheck:
test: ["CMD", "/usr/bin/resume-server", "--health-check"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
# The project network gets an explicit, small subnet. The host's docker daemon
# has every default address pool allocated by other projects ("all predefined
# address pools have been fully subnetted"), so an implicit compose network
# cannot be created. Pinning /28 of RFC1918 space that no other bridge or route
# on .13 uses keeps this deploy independent of the host's pool exhaustion and
# does not touch any other container. Single-service stack: two addresses is
# plenty.
networks:
default:
ipam:
config:
- subnet: 10.89.0.0/28
gateway: 10.89.0.1
# Phase 4/5 add at most three more containers (Qdrant, Prefect worker, query
# service) inside the 4-container and 1.0 GiB budget in AGENTS.md §4.3. Mode A
# needs none of them and must keep working when they are stopped (test A1).

390
deploy/deploy.sh Executable file
View File

@@ -0,0 +1,390 @@
#!/usr/bin/env bash
# deploy.sh — release, deploy and roll back the Mode A site (docs/phase-3-deploy.md §3.1).
#
# ./deploy/deploy.sh build, test, stage a release, flip `current`,
# start the container, gate on /healthz
# ./deploy/deploy.sh --rollback put the previous release back, in one command
# ./deploy/deploy.sh --status what is live, what is previous, is it healthy
# ./deploy/deploy.sh --release-dir D deploy an already-built release directory
# (copied into releases/ first; used by the
# rollback rehearsal below)
#
# Layout on the runtime host — the release directory, not the image tag, is the
# unit that runs, so a rollback cannot publish bytes that were never tested:
#
# /home/sam/deployment/resume/
# releases/<utc-timestamp>-<git-sha>/{bin/resume-server,dist/,release.json}
# current -> releases/<…> what the container is running
# previous -> releases/<…> what --rollback restores
#
# A deploy is: build → test → stage → flip `current` → build the image from that
# release directory → `compose up -d` → `curl /healthz` until ready. Any failure
# after the flip puts `current` and the container back on the previous release
# and exits non-zero. No step is a model decision (AGENTS.md §4.6); this script
# is the only thing that publishes.
#
# Rollback rehearsal (Definition of Done: "a deliberate bad build proves rollback"):
#
# good=$(readlink ~/deployment/resume/current)
# bad=$(mktemp -d) && mkdir -p "$bad/bin" "$bad/dist"
# printf '#!/bin/sh\nexit 1\n' >"$bad/bin/resume-server" # binary that cannot start
# chmod +x "$bad/bin/resume-server"
# cp -a ~/deployment/resume/"$good"/dist/. "$bad/dist/" # valid pages
# ./deploy/deploy.sh --release-dir "$bad" # expect exit 1
# readlink ~/deployment/resume/current # still $good
#
# Run it on the runtime host (.13) from a checkout of this repository. It never
# reads content/ and never calls a model.
set -Eeuo pipefail
APP=ai-resume # image tag (fixed by Phase 3 §3.2)
PROJECT=ai-resume-site # compose project; distinct from the older
# /home/sam/deployment/ai-resume project on .13
PORT=8096
HEALTH_PATH=/healthz
ROOT=${RESUME_DEPLOY_ROOT:-/home/sam/deployment/resume}
RELEASES=$ROOT/releases
CURRENT=$ROOT/current
PREVIOUS=$ROOT/previous
KEEP=${RESUME_KEEP_RELEASES:-5} # release directories kept on disk
HEALTH_TRIES=${RESUME_HEALTH_TRIES:-40} # seconds to wait for /healthz
REPO=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
COMPOSE=$REPO/deploy/compose.yml
DOCKERFILE=$REPO/deploy/Dockerfile
MODE=deploy
RELEASE_DIR_OVERRIDE=
log() { printf '%s %s
' "$(date -u +%H:%M:%SZ)" "$*" >&2; }
warn() { printf '%s WARN %s
' "$(date -u +%H:%M:%SZ)" "$*" >&2; }
die() { printf '%s FAIL %s
' "$(date -u +%H:%M:%SZ)" "$*" >&2; exit 1; }
on_err() {
local rc=$? line=$1
printf '%s FAIL line %s exited %s
' "$(date -u +%H:%M:%SZ)" "$line" "$rc" >&2
exit "$rc"
}
trap 'on_err $LINENO' ERR
usage() {
sed -n '2,13p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
while [ $# -gt 0 ]; do
case $1 in
--rollback) MODE=rollback ;;
--status) MODE=status ;;
--release-dir) shift; RELEASE_DIR_OVERRIDE=${1:-} ;;
--keep) shift; KEEP=${1:-5} ;;
-h|--help) usage 0 ;;
*) printf 'unknown argument: %s
' "$1" >&2; usage 1 ;;
esac
shift
done
# ---------------------------------------------------------------- helpers ---
compose() { docker compose -f "$COMPOSE" -p "$PROJECT" "$@"; }
current_target() { readlink -- "$CURRENT" 2>/dev/null || true; }
previous_target() { readlink -- "$PREVIOUS" 2>/dev/null || true; }
# flip <relative-path-under-ROOT> — replace the symlink atomically. A reader sees
# either the old release or the new one, never a missing link.
flip() {
ln -sfn "$1" "$CURRENT.tmp"
mv -Tf "$CURRENT.tmp" "$CURRENT"
}
release_name_of() { basename "$1"; }
# The tag deploy/compose.yml runs. Releases are distinguished by content, not by
# tag, so compose.yml keeps its fixed image name.
image_name() { printf '%s:local' "$APP"; }
# The port must be ours, or free. 8095 belongs to offsite.lab.
check_port() {
local listening
listening=$(ss -ltnH "sport = :$PORT" 2>/dev/null || true)
if [ -n "$listening" ]; then
if [ -z "$(compose ps -q resume 2>/dev/null)" ]; then
printf '%s
' "$listening" >&2
die "port $PORT is held by something that is not $APP; pick the next free port and record it in reference/stack.md"
fi
log "port $PORT is held by our own container (expected on a redeploy)"
fi
}
check_tools() {
command -v docker >/dev/null || die "docker is not on PATH"
docker compose version >/dev/null 2>&1 || die "docker compose v2 is not available"
command -v curl >/dev/null || die "curl is not on PATH"
command -v ss >/dev/null || die "ss (iproute2) is not on PATH"
mkdir -p "$RELEASES"
}
memory_report() {
local avail
avail=$(awk '/^MemAvailable:/ {print int($2/1024)}' /proc/meminfo)
log "host memory available: ${avail} MiB — the container cap is 120 MiB; .13 is memory-tight by design"
if [ "${avail:-0}" -lt 512 ]; then
warn "under 512 MiB available; the Go build may push this host into swap"
fi
}
# --------------------------------------------------------------- build ------
build_and_test() {
log "build + test in $REPO"
(
cd "$REPO"
rm -rf dist
if command -v nix >/dev/null 2>&1; then
nix develop . -c bash -c 'make validate test dist'
elif command -v templ >/dev/null 2>&1 && command -v go >/dev/null 2>&1; then
make validate test dist
else
echo "no toolchain: neither 'nix develop' nor templ+go is available" >&2
exit 1
fi
)
[ -f "$REPO/bin/resume-server" ] || die "build produced no bin/resume-server"
[ -f "$REPO/dist/index.html" ] || die "build produced no dist/index.html"
log "build ok: $(jq -r '"\(.pages) pages, \(.documents) documents, \(.fragments) fragments"' "$REPO/dist/build.json" 2>/dev/null || echo 'dist/build.json unreadable')"
}
# -------------------------------------------------------------- release -----
write_manifest() {
local dir=$1 sha dirty
sha=$(git -C "$REPO" rev-parse HEAD 2>/dev/null || echo unknown)
dirty=false
git -C "$REPO" diff --quiet 2>/dev/null || dirty=true
cat >"$dir/release.json" <<JSON
{
"release": "$(basename "$dir")",
"image": "$(image_name)",
"git_sha": "$sha",
"git_dirty": $dirty,
"built_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"host": "$(hostname)",
"pages": $(jq -r '.pages // 0' "$REPO/dist/build.json" 2>/dev/null || echo 0),
"documents": $(jq -r '.documents // 0' "$REPO/dist/build.json" 2>/dev/null || echo 0)
}
JSON
}
stage_release() {
local name dest sha
sha=$(git -C "$REPO" rev-parse HEAD 2>/dev/null || echo unknown)
name=$(date -u +%Y%m%dT%H%M%SZ)-${sha:0:7}
dest=$RELEASES/$name
mkdir -p "$dest/bin" "$dest/dist"
install -m 0755 "$REPO/bin/resume-server" "$dest/bin/resume-server"
cp -a "$REPO/dist/." "$dest/dist/"
write_manifest "$dest"
printf '%s
' "$dest"
}
# Copy an externally built release into releases/ so that every deployable
# directory lives under exactly one root and `current` never dangles.
adopt_release() {
local src dest
src=$(cd "$1" && pwd)
[ -f "$src/bin/resume-server" ] || die "$src has no bin/resume-server"
[ -f "$src/dist/index.html" ] || die "$src has no dist/index.html"
dest=$RELEASES/$(date -u +%Y%m%dT%H%M%SZ)-manual
mkdir -p "$dest"
cp -a "$src/." "$dest/"
write_manifest "$dest"
printf '%s
' "$dest"
}
# ---------------------------------------------------------------- image -----
build_image() {
log "docker build from $1"
docker build --file "$DOCKERFILE" --tag "$(image_name)" "$1" 1>&2
}
# ------------------------------------------------------------------ run -----
start() {
log "docker compose up -d ($1)"
compose up -d --no-build --force-recreate 1>&2
}
wait_healthy() {
local i body
for i in $(seq 1 "$HEALTH_TRIES"); do
if body=$(curl -fsS --max-time 3 "http://127.0.0.1:$PORT$HEALTH_PATH" 2>/dev/null); then
if printf '%s' "$body" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"ok"'; then
log "health gate passed after ${i}s"
return 0
fi
fi
sleep 1
done
warn "health gate failed after ${HEALTH_TRIES}s; last response:"
curl -sS --max-time 3 "http://127.0.0.1:$PORT$HEALTH_PATH" >&2 || true
compose logs --tail 40 resume >&2 || true
return 1
}
# Definition-of-Done evidence, checked on every deploy instead of by hand:
# mem_limit == memswap_limit == the value declared in compose.yml, and the port
# published on 127.0.0.1 only.
assert_shape() {
local cid mem swap ip declared
declared=$(awk '/^[[:space:]]*mem_limit:/ {gsub(/[^0-9]/, "", $2); print $2}' "$COMPOSE")
cid=$(compose ps -q resume 2>/dev/null || true)
[ -n "$cid" ] || die "no container for service 'resume'"
mem=$(docker inspect -f '{{.HostConfig.Memory}}' "$cid")
swap=$(docker inspect -f '{{.HostConfig.MemorySwap}}' "$cid")
[ "$mem" = "$swap" ] || die "mem_limit ($mem) != memswap_limit ($swap)"
[ "$mem" = "$((declared * 1024 * 1024))" ] || die "mem_limit is $mem, expected ${declared} MiB"
ip=$(docker inspect -f '{{range $p, $b := .HostConfig.PortBindings}}{{range $b}}{{.HostIp}} {{end}}{{end}}' "$cid" | xargs)
[ "$ip" = "127.0.0.1" ] || die "published on '${ip}', expected 127.0.0.1 only"
log "shape ok: mem_limit == memswap_limit == ${declared} MiB, published on 127.0.0.1:$PORT"
}
deploy_release() {
local dir old name
dir=$1
name=$(release_name_of "$dir")
[ -d "$dir" ] || die "release directory does not exist: $dir"
[ -f "$dir/bin/resume-server" ] || die "$dir has no bin/resume-server"
[ -f "$dir/dist/index.html" ] || die "$dir has no dist/index.html — a build that cannot serve a landing page is not a release"
build_image "$dir"
old=$(current_target)
# The flip happens before the container change, so `current` always describes
# what is being started. The release it replaced becomes `previous`.
flip "releases/$name"
if [ -n "$old" ] && [ "$old" != "releases/$name" ]; then
ln -sfn "$old" "$PREVIOUS"
log "previous -> $old"
fi
if start "$name" && wait_healthy && assert_shape; then
printf '%s
' "$(date -u +%Y-%m-%dT%H:%M:%SZ) deploy releases/$name ok" >>"$ROOT/deploy.log"
log "deployed $name"
prune
return 0
fi
if [ -z "$old" ] || [ "$old" = "releases/$name" ]; then
warn "first deploy failed and there is no previous release; leaving the container down"
compose down >&2 || true
rm -f "$CURRENT"
return 1
fi
warn "deploy of $name failed; restoring $old"
flip "$old"
build_image "$ROOT/$old" || die "could not rebuild the previous image; container state unknown"
if start "$old" && wait_healthy && assert_shape; then
printf '%s
' "$(date -u +%Y-%m-%dT%H:%M:%SZ) rolled back to $old after failed deploy of releases/$name" >>"$ROOT/deploy.log"
die "deploy of releases/$name failed; rolled back to $old (the failed release is kept for inspection)"
fi
die "deploy of releases/$name failed AND rollback to $old failed — container state is unknown; run '$0 --status' and read $ROOT/deploy.log"
}
do_rollback() {
local cur prev
cur=$(current_target)
prev=$(previous_target)
[ -n "$prev" ] || die "no previous release recorded; nothing to roll back to"
[ "$prev" != "$cur" ] || die "previous and current are the same release ($cur); nothing to roll back"
[ -d "$ROOT/$prev" ] || die "previous release directory is missing: $ROOT/$prev"
log "rolling back: $cur -> $prev"
build_image "$ROOT/$prev"
flip "$prev"
if start "$prev" && wait_healthy && assert_shape; then
[ -n "$cur" ] && ln -sfn "$cur" "$PREVIOUS"
printf '%s
' "$(date -u +%Y-%m-%dT%H:%M:%SZ) rollback $cur -> $prev" >>"$ROOT/deploy.log"
log "rolled back to $prev; $cur is now previous"
return 0
fi
warn "rollback to $prev failed; returning to $cur"
flip "$cur"
build_image "$ROOT/$cur" || die "could not rebuild $cur; container state unknown"
start "$cur" && wait_healthy || die "return to $cur failed; container state unknown"
die "rollback to $prev failed; $cur was restored"
}
# Keep the newest KEEP releases plus current and previous, nothing else.
prune() {
local cur prev name dir
cur=$(current_target); prev=$(previous_target)
for dir in $(ls -1dt "$RELEASES"/*/ 2>/dev/null | tail -n +"$((KEEP + 1))"); do
name="releases/$(basename "$dir")"
if [ "$name" = "$cur" ] || [ "$name" = "$prev" ]; then continue; fi
rm -rf -- "$dir"
log "pruned $name"
done
}
do_status() {
local cur prev body
cur=$(current_target); prev=$(previous_target)
body=$(curl -fsS --max-time 3 "http://127.0.0.1:$PORT$HEALTH_PATH" 2>/dev/null || true)
printf 'root %s
' "$ROOT"
printf 'current %s
' "${cur:-<none>}"
printf 'previous %s
' "${prev:-<none>}"
if [ -n "$body" ]; then
printf 'health %s
' "$(printf '%s' "$body" | jq -c '{status, pages: .static.pages, search: .search.ready}' 2>/dev/null || printf '%s' "$body")"
else
printf 'health unreachable
'
fi
compose ps || true
}
# ------------------------------------------------------------------ main ----
case $MODE in
status)
do_status
;;
rollback)
check_tools
do_rollback
;;
deploy)
check_tools
check_port
memory_report
if [ -n "$RELEASE_DIR_OVERRIDE" ]; then
deploy_release "$(adopt_release "$RELEASE_DIR_OVERRIDE")"
else
build_and_test
deploy_release "$(stage_release)"
fi
log "live: $(current_target)"
;;
esac

64
docs/content-redaction.md Normal file
View File

@@ -0,0 +1,64 @@
# Content redaction pass — what was excluded and why
**Scope:** every document under `content/`, authored in Phase 1 (issue AUD-131).
**Date of pass:** 2026-10-26.
**Sources read for this pass:** `../resume_ai_system/plan/EVIDENCE-NOTES.md`, `../resume_ai_system/plan/draft-content.md`, `../resume_ai_system/plan/20-content-spine.md`, `../resume_ai_system/old_projects.md`, `resume_2025.docx`, `reference/content-map.md`, `reference/decisions.md`, `reference/stack.md`, and the Obsidian backup notes.
Acceptance test C6 covers this: *no family, address, credential or third-party personal data in pages or corpus.* The list below is what was removed to satisfy it, and the reason for each removal.
## Excluded — personal identifiers
| Item | Where it appeared | Why excluded |
|---|---|---|
| Date of birth | `resume_2025.docx`, Personal Details | Not needed to assess capability. Age is a discriminator and an identity-theft input. |
| Phone number | `resume_2025.docx`, Personal Details | Publishing a mobile exposes it to scrapers. Sam has not confirmed a phone number belongs on the site (`reference/stack.md` open items). |
| Street address / suburb | `resume_2025.docx` ("live in Ivanhoe"), `draft-content.md` | Address-level detail is a safety issue and is irrelevant to the work. "Melbourne based" is the published granularity. |
| Email address | `resume_2025.docx` | Kept off the pages in Phase 1. Contact details are an open decision for Sam; the narrative states the policy rather than the address. |
| Passport and visa status | `resume_2025.docx` | Immigration status is not published. Working rights are a conversation, not a web page. |
| Family names and details | `EVIDENCE-NOTES.md` (school portals, children, household) | Never published. The 2020–2026 period is described as "full-time parent to two children" with no names, ages, schools or locations. |
## Excluded — third-party personal data
| Item | Where it appeared | Why excluded |
|---|---|---|
| Names of individual client contacts, agency colleagues and referees | résumé prose | No third party has consented to being named. Clients are represented by brand only, which was public campaign work. |
| Details identifying minors in a personal anecdote | `EVIDENCE-NOTES.md` interests ("a 2 day hike … with 7 13 year old boys") | Rewritten as "a group of teenagers in tow". A count of identifiable children is not needed to make the point. |
| Names of household members appearing in automation examples | `EVIDENCE-NOTES.md` | Household automation is described by function (lighting, audio, doors) and never by person. |
## Excluded — credentials and infrastructure detail
| Item | Where it appeared | Why excluded |
|---|---|---|
| API keys, tokens, passphrases, secret file names | n/a — never copied | Nothing from `~/.config/environment.d/` was read or quoted. No key material enters the repo, per `AGENTS.md` §4.4. |
| Backup passphrase file path and repository passphrase | Obsidian backup note | Publishing the location of a passphrase file is a hint worth withholding. Backups are described by tier, tool and retention only. |
| Hostnames, internal IP addresses, ports-by-host, public DNS names | `reference/stack.md`, Obsidian notes, live inspection | Internal addressing is not needed to judge the platform work. Hosts are described by role ("the runtime host", "the edge host"). |
| Internal service URLs and dashboards | `EVIDENCE-NOTES.md` (the pipeline dashboard) | Described as "a dashboard built to keep track of it". A private URL on a public page is an invitation. |
| Third-party account identifiers, billing detail | n/a | Not in the sources and not wanted. |
## Excluded — unverifiable or invented content
| Item | Where it appeared | Why excluded |
|---|---|---|
| "42 devices" | recovered site content | Explicitly withdrawn in `draft-content.md`. Not used. |
| "~800ms end-to-end" latency | recovered site content | Explicitly withdrawn. Not used. |
| Invented venues, competition results, swell sizes, distances and restaurants | recovered pursuits content | Marked ❌ SUSPECT and confirmed as generated. Replaced with one honest line per pursuit. |
| "Diajio" | recovered client list | Spelling error for **Diageo**. Corrected, not carried. |
| Screenshot of the BBC National Archive engagement | recovered site content | The work was an internal tool. Described only; no image. |
## Metric discipline
Every numeric `metric` in `content/` is traced to a source line, and the trace is recorded per metric in the completion comment on AUD-131. Two figures were deliberately **not** promoted:
- **Businesses: 3 active, 12 cumulative.** The site says three are running now and twelve adopted it over time. Conflating them would be the easiest inflation available on this site.
- **Where-Woof: deployed, pre-market.** The word "commercial" is not used about it anywhere.
## Two numbers reported, not smoothed
1. **Agencies: 22 published, 23 in the summary material.** The recovered, verified list holds twenty-two agency names. The published count is the count of the list. If a name is missing, the number rises to twenty-three and the note in `content/history/l3/history-client-wall.md` is deleted.
2. **Period start dates.** Where the sources give no start date, the period is anchored on the homelab year (2020) and the page says so. Exact start dates are Sam's to correct.
## Open questions for Sam
- Contact details: email only, or also a phone number? (`about-narrative.md` states the current policy: email, supplied directly.)
- Is a Melbourne suburb to be published? Currently excluded.
- The missing twenty-third agency, if there is one.

184
docs/content-schema.md Normal file
View File

@@ -0,0 +1,184 @@
# Content schema (frozen)
This document states the front-matter schema that every file in `content/` must carry, and the rules the validator enforces. The schema is the one in [`AGENTS.md` §7](../AGENTS.md#7-content-conventions). Read that section first; this page is the exact, testable restatement of it.
**The schema is frozen.** Any change to the fields, their types, their enum values or the artefact rules is a **breaking change**: it invalidates every existing file in `content/` and both renderers. A change requires **Sam's written approval** before it is made. Do not widen or narrow the schema to make a document validate — fix the document.
---
## 1. How to validate
```
make validate # the gate: exits 0 only on a clean tree
make test # validator self-test, 20 fixture cases
```
`make validate` is the command CI and `deploy.sh` call in later phases. Run it before every content commit.
Both targets invoke:
```
python3 tools/validate_content.py [--root DIR] [--content DIR] [--quiet]
```
Exit codes: `0` clean (also when `content/` does not exist yet), `1` at least one violation, `2` usage or internal error.
---
## 2. Document shape
* Every `.md` file under `content/` is a **document** and is validated. Other file types under `content/` (images, SVG, PDF) are **assets** and are not validated — they exist to be referenced.
* A document starts with a front-matter block on line 1:
```
---
<fields>
---
```
* The closing delimiter is `---` (or `...`). Everything after it is the Markdown body and is not validated.
* One document per file. One front-matter block per file.
* `content/` is organised by area:
```
content/
ai/ platform/ edge/ products/ history/ about/ # documents, plus l3/ below each
assets/ # diagrams, screenshots, logos
```
---
## 3. Fields
All thirteen fields are **required on every document**, including L3 stubs (which use empty lists). Order does not matter.
| Field | Type | Rule |
|---|---|---|
| `id` | string | Lowercase kebab-case: `a-z`, `0-9`, single hyphens between parts. **Must equal the file name** without `.md`. **Unique** across all of `content/`. |
| `area` | enum | One of `ai`, `platform`, `edge`, `products`, `history`, `about`. |
| `level` | integer | `1`, `2` or `3` (depth level: L1 landing, L2 capability, L3 artefact). |
| `title` | string | Non-empty. Corporate capability framing (see `AGENTS.md` §7); product names are evidence, not headings. |
| `claim` | string | Non-empty. One sentence a hiring manager can verify. |
| `confidence` | enum | One of `delivered`, `running`, `in-progress`, `abandoned`, `historic`. **Displayed on the page.** Never promote a value to make a page read better. |
| `period` | string | Non-empty. Free text, e.g. `"2023–"`, `"2019–2024"`, `"2000–2014"`. |
| `tech` | list of strings | Non-empty strings. May be `[]`. Product and technology names belong here, not in `title`. |
| `metrics` | list of maps | May be `[]`. Each item is exactly `{label, value}`. Both non-empty. `value` may be a number (`8`) or a string (`"50+"`). Every value must trace to the evidence notes — the validator checks shape, not truth. |
| `artefacts` | list of maps | May be `[]`. Each item is exactly `{type, label, url}`. See §4. |
| `public` | boolean | `true` or `false`. `false` keeps the document out of the published output. |
| `last_verified` | date | `YYYY-MM-DD` and a real calendar date. |
| `tags` | list of strings | Non-empty strings. May be `[]`. |
**Unknown fields are rejected — at the top level and inside `metrics[]` and `artefacts[]`.** There is no extension point. A field that an author thinks is useful is a schema change (§6).
The one exception to "all required": nothing. `tech: []`, `metrics: []`, `artefacts: []` and `tags: []` are the correct way to say "this document has none". An omitted field is a violation; an empty list is not.
---
## 4. Artefact links — dangling references are fatal
Every `artefacts[].url` must resolve to a file that exists in this repository. This is the rule that keeps every claim one step away from its proof.
| `url` form | Resolves against | Example |
|---|---|---|
| `content/...` or any path without a leading `./` or `../` | the repository root | `content/edge/l3/audio-architecture.md` |
| `./...` or `../...` | the directory of the document | `./l3/audio-architecture.md` |
Rules:
* The target must be an existing **file** — an L3 stub document (`.md`) or an asset (`.svg`, `.png`, `.pdf`, …). A directory is a violation.
* **External URLs (`https://…`) are not allowed.** If the artefact lives outside the repository (a Gitea repo, a video), create an in-repo L3 stub document that describes and links it, and point `url` at that stub. This keeps every drill-down verifiable offline and keeps Mode A free of third-party dependencies.
* Root-absolute paths (`/content/...`) are not allowed — they do not survive a release directory move.
* A path that escapes the repository is a violation.
`type` is one of `repo`, `image`, `video`, `doc` and describes the artefact behind the link, not the file extension of the stub.
---
## 5. Cross-document rules
* **`id` is unique** across `content/`. Two files with the same `id` are a violation on the second file.
* **`id` matches the file name.** `content/edge/snapcast.md` must have `id: snapcast`; an L3 stub `content/edge/l3/snapcast-code.md` must have `id: snapcast-code`.
---
## 6. Changing the schema
Breaking (requires Sam's written approval, then a migration of every file):
* adding, removing or renaming a field
* changing a field's type
* adding or removing an enum value (`area`, `level`, `confidence`, artefact `type`)
* relaxing or tightening a rule (e.g. allowing external artefact URLs)
Not a schema change: adding documents, filling empty lists, adding tags or metrics within the existing shape.
A change that supersedes this document must update, in one commit: `AGENTS.md` §7, this file, `tools/validate_content.py`, and the fixtures under `test/fixtures/`.
---
## 7. Error output
One line per violation, on stdout:
```
<path>:<line>: <field>: <rule broken>
```
Examples:
```
content/edge/audio.md:14: artefacts[0].url: dangling reference: 'content/edge/l3/not-written-yet.md' does not exist (create the L3 stub or asset)
content/edge/audio.md:16: author: unknown field (the schema is frozen; see docs/content-schema.md)
content/edge/audio.md:3: area: must be one of ai, platform, edge, products, history, about
content/edge/audio.md:1: claim: missing required field
```
A missing field is reported on line 1, because there is no line that carries it. A malformed front-matter block is reported as `<front-matter>: invalid YAML: …` and stops validation of that document only.
The final line is either `FAIL: N violation(s) in M document(s)` or `OK: M document(s) validated, 0 violations`.
---
## 8. Front-matter dialect the parser accepts
The validator contains its own small YAML-subset parser so the repository needs no third-party dependency (`AGENTS.md` §4: the stack is frozen, and the `.27` build host has no Go toolchain yet). It accepts:
* block mappings, block sequences, and flow collections: `tech: [a, b]`, `metrics: [{label: "endpoints", value: 8}]`
* single- and double-quoted scalars, plain scalars
* literal (`|`) and folded (`>`) block scalars, including `|-` / `>-`
* `#` comments outside quoted strings
* blank lines
It does **not** accept: anchors/aliases (`&`, `*`), explicit tags (`!!str`), multi-document files, complex keys, tab indentation. Use two or four spaces, consistently within a block.
Two consequences worth knowing:
* A quoted string must be closed. `claim: "text # not a comment` is an error, not a comment.
* `#` after a space ends a plain scalar, as in YAML. Quote values that contain ` #`.
---
## 9. What the validator does not check
Enforced elsewhere in Phase 1, and reviewed by Sam:
* that a `metric` value appears in the evidence notes (no invented numbers)
* that `confidence` matches reality — the validator cannot know; it only forces an honest label to be present
* the redaction pass (no family names, address, phone, credentials)
* that every L1 claim has a metric and at least one L3 artefact
* corporate framing of headings
---
## 10. Implementation and tests
| Path | Purpose |
|---|---|
| `tools/validate_content.py` | The validator. One file, Python 3 standard library only, no framework. |
| `Makefile` | `validate` and `test` targets. |
| `test/fixtures/<case>/content/` | Fixture trees. `ok-*` cases must pass, `fail-*` cases must fail. |
| `test/fixtures/<case>/expect.txt` | Substrings that `fail-*` output must contain, one per line. |
Fixture cases cover: missing field, `area` enum, `level` enum, `confidence` enum, `last_verified` format and calendar validity, empty `metrics[].label`, artefact `type` enum, artefact missing `url`, dangling artefact, external artefact URL, duplicate `id`, `id`/filename mismatch, unknown top-level field, unknown nested artefact field, missing front-matter, malformed YAML. `ok-empty` proves an empty `content/` exits 0; `ok-block-scalars` proves the block-scalar and block-sequence dialects.
Fixtures live under `test/`, never under `content/`.

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1792773339,
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

27
flake.nix Normal file
View File

@@ -0,0 +1,27 @@
{
description = "Ai Resume — build environment (Go + templ + SQLite). Node, Vite and bundlers are deliberately absent.";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forAll = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in {
devShells = forAll (pkgs: {
default = pkgs.mkShell {
packages = [
pkgs.go
pkgs.templ
pkgs.sqlite # the sqlite3 CLI, for inspecting the FTS5 index
pkgs.gnumake
pkgs.curl
];
shellHook = ''
echo "ai-resume dev shell — go $(go version | cut -d' ' -f3), templ $(templ version)"
echo "targets: make generate build test dev"
'';
};
});
};
}

25
go.mod Normal file
View File

@@ -0,0 +1,25 @@
module gitea.lab.audasmedia.com.au/sam/ai-resume
go 1.25.0
require (
github.com/a-h/templ v0.3.1020
github.com/alecthomas/chroma/v2 v2.27.0
github.com/yuin/goldmark v1.8.6
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.41.0 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.34.5 // indirect
)

46
go.sum Normal file
View File

@@ -0,0 +1,46 @@
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=

456
internal/content/content.go Normal file
View File

@@ -0,0 +1,456 @@
// Package content loads the Markdown source of truth in content/ into documents
// that both renderers (Mode A static pages, Mode B corpus) read.
//
// The schema is frozen; see docs/content-schema.md. This package does not
// re-implement the validator (tools/validate_content.py is the gate). It does
// enforce the invariants the renderer needs, and it fails loudly rather than
// rendering a broken page.
package content
import (
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
"gopkg.in/yaml.v3"
)
// Confidence values. These are displayed on the page and are never promoted.
const (
ConfidenceDelivered = "delivered"
ConfidenceRunning = "running"
ConfidenceInProgress = "in-progress"
ConfidenceAbandoned = "abandoned"
ConfidenceHistoric = "historic"
)
// Area is one of the six top-level navigation areas. Structure lives in code
// (it comes from reference/content-map.md, not from the content tree); the
// documents that fill it live in content/.
type Area struct {
Key string
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",
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",
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",
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",
Blurb: "Three active businesses, built and run rather than advised on."},
{Key: "history", Title: "History", Role: "", Gutter: "HI",
Blurb: "2000–2014: agencies, clients, engagements, and the code that survives from them."},
{Key: "about", Title: "About", Role: "", Gutter: "AB",
Blurb: "The narrative, the non-IT work that came first, education, and how to get in touch."},
}
// Positioning is the site's single line of introduction.
const (
Positioning = "Twenty-five years shipping software. Now building agent systems, the platforms under them, and the hardware at the edge."
SupportLine = "…and it all runs unattended."
Location = "Melbourne"
)
// AreaByKey returns the area with the given key.
func AreaByKey(key string) (Area, bool) {
for _, a := range Areas {
if a.Key == key {
return a, true
}
}
return Area{}, false
}
// Metric is one approved chip on a page, e.g. {label: "edge nodes", value: 20}.
type Metric struct {
Label string `yaml:"label"`
Value any `yaml:"value"`
}
// String renders the chip value with its label: "20 edge nodes".
func (m Metric) String() string {
return fmt.Sprintf("%v %s", m.Value, m.Label)
}
// Artefact is a drill-down link. Every URL must resolve to a file in this
// repository; nothing external is allowed.
type Artefact struct {
Type string `yaml:"type"` // repo | image | video | doc
Label string `yaml:"label"`
URL string `yaml:"url"`
}
// Document is one Markdown file plus its front-matter.
type Document struct {
ID string `yaml:"id"`
Area string `yaml:"area"`
Level int `yaml:"level"`
Title string `yaml:"title"`
Claim string `yaml:"claim"`
Confidence string `yaml:"confidence"`
Period string `yaml:"period"`
Tech []string `yaml:"tech"`
Metrics []Metric `yaml:"metrics"`
Artefacts []Artefact `yaml:"artefacts"`
Public bool `yaml:"public"`
LastVerified string `yaml:"last_verified"`
Tags []string `yaml:"tags"`
// Derived by Load.
Path string `yaml:"-"` // path relative to the content root, e.g. edge/edge-fleet.md
URL string `yaml:"-"` // canonical page URL, e.g. /edge/edge-fleet/
HTML string `yaml:"-"` // rendered body
Plain string `yaml:"-"` // body reduced to text, for the search index
bodyRaw string // Markdown body, rendered by the markdown renderer
// Backlinks are the documents that reference this one as an artefact.
Backlinks []*Document `yaml:"-"`
}
// ResolvedArtefact is an Artefact with its target resolved.
type ResolvedArtefact struct {
Artefact
Target *Document // non-nil when the artefact is an L3/L2 document
File string // absolute path on disk
Rel string // path relative to the content root, e.g. assets/x.svg
Href string // site URL: a page URL for documents, /assets/... for files
Kind string // Fragment kind: text | code | diagram | image | video | links
}
// Set is every loaded document, indexed.
type Set struct {
Root string
Docs []*Document
byID map[string]*Document
byRel map[string]*Document
}
// ByID returns the document with the given id.
func (s *Set) ByID(id string) (*Document, bool) {
d, ok := s.byID[id]
return d, ok
}
// ByRel returns the document at the given content-relative path.
func (s *Set) ByRel(rel string) (*Document, bool) {
d, ok := s.byRel[rel]
return d, ok
}
// InArea returns the public documents for an area, ordered L1, L2, L3.
func (s *Set) InArea(key string) []*Document {
var out []*Document
for _, d := range s.Docs {
if d.Area == key {
out = append(out, d)
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Level != out[j].Level {
return out[i].Level < out[j].Level
}
return out[i].Title < out[j].Title
})
return out
}
// AreaLanding returns the L1 document for an area.
func (s *Set) AreaLanding(key string) (*Document, bool) {
for _, d := range s.InArea(key) {
if d.Level == 1 {
return d, true
}
}
return nil, false
}
// Load reads every Markdown file under root. Documents with public: false are
// loaded (so links to them still resolve) but are not published.
func Load(root string) (*Set, error) {
info, err := os.Stat(root)
if err != nil {
return nil, fmt.Errorf("content root %q: %w", root, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("content root %q is not a directory", root)
}
s := &Set{Root: root, byID: map[string]*Document{}, byRel: map[string]*Document{}}
err = filepath.WalkDir(root, func(p string, e os.DirEntry, err error) error {
if err != nil {
return err
}
name := e.Name()
if e.IsDir() {
if p != root && (strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_")) {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(name, ".md") || strings.HasPrefix(name, ".") {
return nil
}
rel, rerr := filepath.Rel(root, p)
if rerr != nil {
return rerr
}
rel = filepath.ToSlash(rel)
raw, rerr := os.ReadFile(p)
if rerr != nil {
return rerr
}
doc, derr := parse(rel, p, raw)
if derr != nil {
return derr
}
if prev, dup := s.byID[doc.ID]; dup {
return fmt.Errorf("%s: duplicate id %q (already used by %s)", rel, doc.ID, prev.Path)
}
doc.URL = PageURL(doc)
s.Docs = append(s.Docs, doc)
s.byID[doc.ID] = doc
s.byRel[rel] = doc
return nil
})
if err != nil {
return nil, err
}
if len(s.Docs) == 0 {
return nil, fmt.Errorf("content root %q contains no documents", root)
}
sort.SliceStable(s.Docs, func(i, j int) bool {
if s.Docs[i].Area != s.Docs[j].Area {
return areaOrder(s.Docs[i].Area) < areaOrder(s.Docs[j].Area)
}
if s.Docs[i].Level != s.Docs[j].Level {
return s.Docs[i].Level < s.Docs[j].Level
}
return s.Docs[i].ID < s.Docs[j].ID
})
for _, d := range s.Docs {
for _, a := range d.Artefacts {
ra, rerr := s.Resolve(d, a)
if rerr != nil {
return nil, rerr
}
if ra.Target != nil && ra.Target.ID != d.ID {
ra.Target.Backlinks = append(ra.Target.Backlinks, d)
}
}
}
return s, nil
}
func areaOrder(key string) int {
for i, a := range Areas {
if a.Key == key {
return i
}
}
return len(Areas)
}
func parse(rel, abs string, raw []byte) (*Document, error) {
fm, body, err := splitFrontMatter(string(raw))
if err != nil {
return nil, fmt.Errorf("%s: %w", rel, err)
}
var d Document
dec := yaml.NewDecoder(strings.NewReader(fm))
dec.KnownFields(true) // unknown front-matter fields are a schema violation
if err := dec.Decode(&d); err != nil {
return nil, fmt.Errorf("%s: front-matter: %w", rel, err)
}
d.Path = rel
d.bodyRaw = body
if err := d.check(rel); err != nil {
return nil, err
}
return &d, nil
}
// BodyMarkdown returns the raw Markdown body (everything after the front-matter).
func (d *Document) BodyMarkdown() string { return d.bodyRaw }
func (d *Document) check(rel string) error {
switch {
case d.ID == "":
return fmt.Errorf("%s: id is required", rel)
case d.Title == "":
return fmt.Errorf("%s: title is required", rel)
case d.Claim == "":
return fmt.Errorf("%s: claim is required", rel)
}
if _, ok := AreaByKey(d.Area); !ok {
return fmt.Errorf("%s: unknown area %q", rel, d.Area)
}
if d.Level < 1 || d.Level > 3 {
return fmt.Errorf("%s: level must be 1, 2 or 3, got %d", rel, d.Level)
}
switch d.Confidence {
case ConfidenceDelivered, ConfidenceRunning, ConfidenceInProgress, ConfidenceAbandoned, ConfidenceHistoric:
default:
return fmt.Errorf("%s: unknown confidence %q", rel, d.Confidence)
}
if d.LastVerified == "" {
return fmt.Errorf("%s: last_verified is required", rel)
}
stem := strings.TrimSuffix(path.Base(rel), ".md")
if stem != d.ID {
return fmt.Errorf("%s: id %q must equal the file name %q", rel, d.ID, stem)
}
for _, a := range d.Artefacts {
if a.URL == "" || a.Label == "" {
return fmt.Errorf("%s: artefact %q needs both label and url", rel, a.Label)
}
if strings.Contains(a.URL, "://") {
return fmt.Errorf("%s: artefact %q uses an external URL; create an in-repo stub instead", rel, a.URL)
}
}
return nil
}
func splitFrontMatter(src string) (fm, body string, err error) {
src = strings.ReplaceAll(src, "\r\n", "\n")
if !strings.HasPrefix(src, "---\n") {
return "", "", fmt.Errorf("missing front-matter block on line 1")
}
rest := src[len("---\n"):]
for _, delim := range []string{"\n---\n", "\n...\n"} {
if i := strings.Index(rest, delim); i >= 0 {
return rest[:i], rest[i+len(delim):], nil
}
}
return "", "", fmt.Errorf("unterminated front-matter block")
}
// PageURL is the canonical URL for a document. Depth maps onto the URL path so
// the drill-down is legible: /edge/ · /edge/edge-fleet/ · /edge/a/edge-zigbee/.
func PageURL(d *Document) string {
switch d.Level {
case 1:
return "/" + d.Area + "/"
case 2:
return "/" + d.Area + "/" + d.ID + "/"
default:
return "/" + d.Area + "/a/" + d.ID + "/"
}
}
// Resolve maps an artefact reference onto a file, a site URL and a stage
// fragment kind. Paths may be repository-relative ("content/edge/l3/x.md") or
// document-relative ("./l3/x.md", "l3/x.md").
func (s *Set) Resolve(from *Document, a Artefact) (ResolvedArtefact, error) {
rel := a.URL
var target string
switch {
case strings.HasPrefix(rel, "content/"):
target = strings.TrimPrefix(rel, "content/")
case strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, "../"):
return ResolvedArtefact{}, fmt.Errorf("%s: artefact %q must not be root-absolute or escape the repo", from.Path, rel)
default:
target = path.Join(path.Dir(from.Path), rel)
}
target = path.Clean(target)
out := ResolvedArtefact{Artefact: a, Rel: target}
abs := filepath.Join(s.Root, filepath.FromSlash(target))
st, err := os.Stat(abs)
if err != nil {
return out, fmt.Errorf("%s: artefact %q does not resolve to a file (%s)", from.Path, a.URL, target)
}
if st.IsDir() {
return out, fmt.Errorf("%s: artefact %q resolves to a directory", from.Path, a.URL)
}
out.File = abs
if strings.HasSuffix(target, ".md") {
doc, ok := s.byRel[target]
if !ok {
return out, fmt.Errorf("%s: artefact %q is not a loaded document", from.Path, a.URL)
}
out.Target = doc
out.Href = doc.URL
out.Kind = stage.KindText
return out, nil
}
out.Href = "/assets/" + strings.TrimPrefix(target, "assets/")
out.Kind = KindForFile(target)
return out, nil
}
var codeExtensions = map[string]string{
".go": "go", ".js": "javascript", ".mjs": "javascript", ".ts": "typescript",
".nix": "nix", ".php": "php", ".as": "actionscript", ".hpp": "cpp", ".h": "cpp",
".c": "c", ".cpp": "cpp", ".py": "python", ".sh": "bash", ".yaml": "yaml",
".yml": "yaml", ".json": "json", ".css": "css", ".sql": "sql", ".templ": "templ",
}
// KindForFile maps a file extension onto a stage fragment kind.
func KindForFile(name string) string {
ext := strings.ToLower(path.Ext(name))
if lang, ok := codeExtensions[ext]; ok && lang != "" {
return stage.KindCode
}
switch ext {
case ".svg":
return stage.KindDiagram
case ".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif":
return stage.KindImage
case ".mp4", ".webm", ".mov":
return stage.KindVideo
case ".md":
return stage.KindText
}
return stage.KindLinks
}
// LanguageFor returns the chroma language for a file, if it is code.
func LanguageFor(name string) string {
return codeExtensions[strings.ToLower(path.Ext(name))]
}
// Truncate shortens s to at most n runes, on a word boundary where possible.
func Truncate(s string, n int) string {
s = strings.TrimSpace(s)
if len([]rune(s)) <= n {
return s
}
r := []rune(s)[:n]
if i := strings.LastIndex(string(r), " "); i > n/2 {
return strings.TrimSpace(string(r)[:i]) + "…"
}
return strings.TrimSpace(string(r)) + "…"
}
// MetricValue renders a metric value for display.
func MetricValue(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
case int:
return strconv.Itoa(t)
case bool:
return strconv.FormatBool(t)
default:
return fmt.Sprint(t)
}
}

View File

@@ -0,0 +1,180 @@
package content
import (
"bytes"
"fmt"
stdhtml "html"
"io"
"path"
"regexp"
"strings"
"github.com/alecthomas/chroma/v2"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
)
// RendererStyle is the chroma style. Dark by default, matching the rest of
// Sam's surfaces.
const RendererStyle = "github-dark"
// Renderer turns Markdown into server-side HTML. Nothing here runs in a
// browser: goldmark for structure and tables, chroma for code, at build time.
type Renderer struct {
md goldmark.Markdown
set *Set
}
// NewRenderer builds a renderer bound to a document set (needed to rewrite
// relative .md links into site URLs).
func NewRenderer(s *Set) *Renderer {
r := &Renderer{set: s}
r.md = goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.Footnote,
extension.Typographer,
highlighting.NewHighlighting(
highlighting.WithStyle(RendererStyle),
highlighting.WithFormatOptions(chromahtml.WithClasses(true)),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
parser.WithASTTransformers(util.Prioritized(r, 100)),
),
goldmark.WithRendererOptions(html.WithUnsafe()), // trusted, in-repo Markdown
)
return r
}
// Transform rewrites document-relative links into site URLs so the
// pre-rendered pages work as static files. The current document is carried in
// the parser context by Render and Highlight-free callers.
func (r *Renderer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
current, ok := pc.Get(docKey).(*Document)
if !ok || current == nil {
return
}
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch node := n.(type) {
case *ast.Link:
node.Destination = r.rewrite(current, string(node.Destination), false)
case *ast.Image:
node.Destination = r.rewrite(current, string(node.Destination), true)
}
return ast.WalkContinue, nil
})
}
func (r *Renderer) rewrite(from *Document, dest string, image bool) []byte {
if dest == "" || strings.Contains(dest, "://") || strings.HasPrefix(dest, "/") ||
strings.HasPrefix(dest, "#") || strings.HasPrefix(dest, "mailto:") {
return []byte(dest)
}
target := dest
suffix := ""
if i := strings.IndexAny(target, "#?"); i >= 0 {
suffix = target[i:]
target = target[:i]
}
rel := target
if strings.HasPrefix(rel, "content/") {
rel = strings.TrimPrefix(rel, "content/")
} else {
rel = path.Join(path.Dir(from.Path), rel)
}
rel = path.Clean(rel)
if d, ok := r.set.byRel[rel]; ok {
return []byte(d.URL + suffix)
}
if image || KindForFile(rel) != stage.KindLinks {
return []byte(AssetHref(rel) + suffix)
}
return []byte(dest)
}
// docKey carries the document being rendered through the parser context so
// the link rewriter knows what "relative" means.
var docKey = parser.NewContextKey()
// Render fills HTML and Plain on every document in the set.
func (r *Renderer) Render() error {
for _, d := range r.set.Docs {
var buf bytes.Buffer
ctx := parser.NewContext()
ctx.Set(docKey, d)
if err := r.md.Convert([]byte(d.BodyMarkdown()), &buf, parser.WithContext(ctx)); err != nil {
return fmt.Errorf("%s: render Markdown: %w", d.Path, err)
}
d.HTML = buf.String()
d.Plain = PlainText(d.HTML)
}
return nil
}
// Highlight renders a standalone code file with chroma. Used by L3 code
// artefacts and by the make-target that keeps the code exhibits in sync.
func (r *Renderer) Highlight(w io.Writer, lang string, src []byte) error {
lexer := lexers.Get(lang)
if lexer == nil {
lexer = lexers.Analyse(string(src))
}
if lexer == nil {
_, err := fmt.Fprintf(w, "<pre class=\"chroma\"><code>%s</code></pre>", stdhtml.EscapeString(string(src)))
return err
}
lexer = chroma.Coalesce(lexer)
it, err := lexer.Tokenise(nil, string(src))
if err != nil {
return err
}
style := styles.Get(RendererStyle)
if style == nil {
return fmt.Errorf("unknown chroma style %q", RendererStyle)
}
formatter := chromahtml.New(chromahtml.WithClasses(true), chromahtml.WithLineNumbers(false))
return formatter.Format(w, style, it)
}
// ChromaCSS returns the stylesheet for the highlighted classes, so the built
// site makes no colour decisions inline.
func (r *Renderer) ChromaCSS() (string, error) {
style := styles.Get(RendererStyle)
if style == nil {
return "", fmt.Errorf("unknown chroma style %q", RendererStyle)
}
var buf bytes.Buffer
if err := chromahtml.New(chromahtml.WithClasses(true)).WriteCSS(&buf, style); err != nil {
return "", err
}
return buf.String(), nil
}
// AssetHref maps a content-relative asset path onto its published URL.
func AssetHref(rel string) string {
return "/assets/" + strings.TrimPrefix(path.Clean(rel), "assets/")
}
var tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
// PlainText reduces rendered HTML to indexable text.
func PlainText(h string) string {
h = tagRe.ReplaceAllString(h, " ")
h = stdhtml.UnescapeString(h)
return strings.Join(strings.Fields(h), " ")
}

187
internal/search/search.go Normal file
View File

@@ -0,0 +1,187 @@
// Package search builds the SQLite FTS5 index over the static pages.
//
// The index is written by the build step and opened read-only by the server, so
// a search never touches the content tree and works with every AI service
// stopped. The same index is the keyword half of Phase 4's hybrid retrieval:
// its schema is documented in docs/phase-2-notes.md and must not change shape
// without both phases agreeing.
package search
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
_ "modernc.org/sqlite"
)
// Doc is one indexable page.
type Doc struct {
ID string
URL string
Title string
Area string
Level int
Confidence string
Body string
}
// Result is one hit.
type Result struct {
URL string `json:"url"`
Title string `json:"title"`
Area string `json:"area"`
Level int `json:"level"`
Confidence string `json:"confidence"`
Snippet string `json:"snippet"`
Score float64 `json:"score"`
}
// Schema is the FTS5 table. Columns marked UNINDEXED are still selectable.
const schema = `
CREATE VIRTUAL TABLE pages USING fts5(
id UNINDEXED,
url UNINDEXED,
title,
area UNINDEXED,
level UNINDEXED,
confidence UNINDEXED,
body,
tokenize = 'porter unicode61 remove_diacritics 2'
);`
// Build writes a fresh index at path. Any existing file is replaced, so the
// index always describes exactly one build.
func Build(path string, docs []Doc) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
return fmt.Errorf("open index %s: %w", path, err)
}
defer db.Close()
db.SetMaxOpenConns(1)
if _, err := db.Exec(`PRAGMA journal_mode = OFF; PRAGMA synchronous = OFF;`); err != nil {
return err
}
if _, err := db.Exec(schema); err != nil {
return fmt.Errorf("create fts5 table: %w", err)
}
tx, err := db.Begin()
if err != nil {
return err
}
stmt, err := tx.Prepare(`INSERT INTO pages (id, url, title, area, level, confidence, body) VALUES (?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, d := range docs {
if _, err := stmt.Exec(d.ID, d.URL, d.Title, d.Area, d.Level, d.Confidence, d.Body); err != nil {
return fmt.Errorf("index %s: %w", d.ID, err)
}
}
if err := tx.Commit(); err != nil {
return err
}
if _, err := db.Exec(`INSERT INTO pages (pages) VALUES ('optimize')`); err != nil {
return err
}
return nil
}
// Index is a read-only handle on a built index.
type Index struct {
db *sql.DB
path string
}
// Open opens a built index read-only.
func Open(path string) (*Index, error) {
if _, err := os.Stat(path); err != nil {
return nil, fmt.Errorf("search index %s: %w", path, err)
}
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
return &Index{db: db, path: path}, nil
}
// Close releases the handle.
func (i *Index) Close() error { return i.db.Close() }
// Count returns the number of indexed pages.
func (i *Index) Count() (int, error) {
var n int
err := i.db.QueryRow(`SELECT count(*) FROM pages`).Scan(&n)
return n, err
}
// Query runs a keyword search. Every term is matched as a prefix so "zigbee"
// finds "Zigbee" and "routing" finds "routers".
func (i *Index) Query(q string, limit int) ([]Result, error) {
match := MatchExpression(q)
if match == "" {
return nil, nil
}
if limit <= 0 || limit > 50 {
limit = 20
}
rows, err := i.db.Query(`
SELECT url, title, area, CAST(level AS INTEGER), confidence,
snippet(pages, 6, '<mark>', '</mark>', '…', 18),
bm25(pages)
FROM pages
WHERE pages MATCH ?
ORDER BY bm25(pages)
LIMIT ?`, match, limit)
if err != nil {
return nil, fmt.Errorf("search %q: %w", q, err)
}
defer rows.Close()
var out []Result
for rows.Next() {
var r Result
if err := rows.Scan(&r.URL, &r.Title, &r.Area, &r.Level, &r.Confidence, &r.Snippet, &r.Score); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// MatchExpression turns free text into a safe FTS5 query: bare words only, each
// as a prefix, all required. FTS5 operators in the input are never honoured, so
// a visitor cannot shape the query beyond words.
func MatchExpression(q string) string {
terms := strings.FieldsFunc(strings.ToLower(q), func(r rune) bool {
return !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-')
})
if len(terms) == 0 {
return ""
}
out := make([]string, 0, len(terms))
for _, t := range terms {
t = strings.Trim(t, "-")
if t == "" {
continue
}
out = append(out, `"`+t+`"*`)
}
return strings.Join(out, " AND ")
}

View File

@@ -0,0 +1,89 @@
package search
import (
"path/filepath"
"strings"
"testing"
)
// TestMatchExpressionIsWordsOnly is the security-relevant half of search: FTS5
// syntax in visitor input is never honoured, so a query cannot shape the index
// beyond words. It also means "zigbee)" is not an error page.
func TestMatchExpressionIsWordsOnly(t *testing.T) {
cases := []struct{ in, want string }{
{"zigbee", `"zigbee"*`},
{"Zigbee Routers", `"zigbee"* AND "routers"*`},
{`zigbee" OR body:`, `"zigbee"* AND "or"* AND "body"*`},
{`NEAR(a b)`, `"near"* AND "a"* AND "b"*`},
{` `, ``},
{`!!!`, ``},
{"snapcast-audio", `"snapcast-audio"*`},
}
for _, tc := range cases {
if got := MatchExpression(tc.in); got != tc.want {
t.Errorf("MatchExpression(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// TestBuildAndQuery is the §2.5 DoD check: the FTS5 index returns sensible
// results for the three terms the phase document names.
func TestBuildAndQuery(t *testing.T) {
indexFile := filepath.Join(t.TempDir(), "search.db")
docs := []Doc{
{ID: "edge", URL: "/edge/", Title: "Edge & Devices", Area: "edge", Level: 1, Confidence: "running",
Body: "Twenty nodes on constrained hardware. Zigbee routers and a LoRa link."},
{ID: "platform", URL: "/platform/", Title: "Platform & Infrastructure", Area: "platform", Level: 1, Confidence: "running",
Body: "Declarative hosts, networking and storage."},
{ID: "products", URL: "/products/", Title: "Products", Area: "products", Level: 1, Confidence: "delivered",
Body: "Commercial signage on commodity hardware."},
{ID: "ai", URL: "/ai/", Title: "AI & Agentic Systems", Area: "ai", Level: 1, Confidence: "running",
Body: "Model routing and cost control through a single gateway."},
}
if err := Build(indexFile, docs); err != nil {
t.Fatalf("Build: %v", err)
}
idx, err := Open(indexFile)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer idx.Close()
if n, err := idx.Count(); err != nil || n != len(docs) {
t.Fatalf("Count = %d, %v; want %d", n, err, len(docs))
}
cases := []struct{ q, wantURL string }{
{"zigbee", "/edge/"},
{"signage", "/products/"},
{"routing", "/ai/"},
{"ZIGBEE", "/edge/"},
}
for _, tc := range cases {
results, err := idx.Query(tc.q, 10)
if err != nil {
t.Errorf("Query(%q): %v", tc.q, err)
continue
}
if len(results) == 0 {
t.Errorf("Query(%q) found nothing", tc.q)
continue
}
found := false
for _, r := range results {
if r.URL == tc.wantURL {
found = true
}
}
if !found {
t.Errorf("Query(%q) did not return %s; got %+v", tc.q, tc.wantURL, results)
}
if !strings.Contains(results[0].Snippet, "<mark>") {
t.Errorf("Query(%q) snippet has no highlight: %q", tc.q, results[0].Snippet)
}
}
if results, err := idx.Query("!!!", 10); err != nil || len(results) != 0 {
t.Errorf("Query(no words) = %v, %v; want no results and no error", results, err)
}
}

602
internal/server/server.go Normal file
View File

@@ -0,0 +1,602 @@
// Package server serves the pre-rendered Mode A site out of a build directory.
//
// Everything a visitor reads is a file the build wrote: the server renders a
// page only for a keyword search that carries a query, and for the streamed
// panel demonstration. Nothing here calls a model, opens a socket to another
// service or reads the content tree, so stopping Qdrant, OmniRoute or any query
// service cannot change what a visitor sees (acceptance test A1).
//
// The two pieces that are not files are the two things the static build cannot
// pre-render:
//
// GET /search?q=… keyword search over the FTS5 index
// GET /api/stage/stream the stage panel bound to an event stream
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/site"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
)
// Default configuration. The address is loopback on purpose: Caddy is the only
// public listener (AGENTS.md §5).
const (
DefaultAddr = "127.0.0.1:8096"
DefaultDist = "dist"
DefaultStreamDelay = 350 * time.Millisecond
)
// Cache policies. Pages are rebuilt on deploy and must not be stale across a
// release, so HTML is revalidated. Unhashed assets get an hour at the origin;
// Phase 3's Caddy block sets the long immutable cache for the public edge.
const (
pageCache = "no-cache"
assetCache = "public, max-age=3600"
fragCache = "public, max-age=300"
)
// Config is what the server needs to start. Every field has a usable default so
// that the same binary runs under systemd, compose and `make dev`.
type Config struct {
// Addr is the listen address. Default 127.0.0.1:8096.
Addr string
// DistDir is a directory written by cmd/build. Default "dist".
DistDir string
// StreamDelay paces the streamed panel demonstration. Default 350ms.
StreamDelay time.Duration
// Logger receives one line per request. Default: JSON to stdout.
Logger *slog.Logger
}
func (c *Config) defaults() {
if c.Addr == "" {
c.Addr = DefaultAddr
}
if c.DistDir == "" {
c.DistDir = DefaultDist
}
if c.StreamDelay <= 0 {
c.StreamDelay = DefaultStreamDelay
}
if c.Logger == nil {
c.Logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
}
}
// Server serves one build directory.
type Server struct {
cfg Config
dist string
shell site.Shell
log *slog.Logger
// built is nil when the directory has no readable build.json; the site is
// still served, but /healthz reports static as not ready.
built *site.BuildInfo
builtErr error
// manifest is nil when the build has no stage fragments. Only the streamed
// demonstration needs it.
manifest *stage.Manifest
manifestErr error
// index is nil when the FTS5 index could not be opened. Search then says so
// and the rest of the site is unaffected.
index *search.Index
indexErr error
handler http.Handler
}
// New opens a build directory. It does not fail when the directory is
// incomplete: it starts, serves whatever is there and lets /healthz report why
// it is not ready, which is what a deploy script needs to see.
func New(cfg Config) (*Server, error) {
cfg.defaults()
abs, err := filepath.Abs(cfg.DistDir)
if err != nil {
return nil, err
}
cfg.DistDir = abs
st, err := os.Stat(abs)
if err != nil {
return nil, fmt.Errorf("build directory %s: %w", abs, err)
}
if !st.IsDir() {
return nil, fmt.Errorf("build directory %s is not a directory", abs)
}
s := &Server{
cfg: cfg,
dist: abs,
shell: site.NewShell(),
log: cfg.Logger,
}
s.built, s.builtErr = site.LoadBuildInfo(abs)
if s.built == nil && s.builtErr == nil {
s.builtErr = errors.New("build.json is missing")
}
if s.builtErr != nil {
s.log.Warn("build metadata unavailable", "dist", abs, "error", s.builtErr)
}
if raw, err := os.ReadFile(filepath.Join(abs, filepath.FromSlash(site.ManifestFile))); err != nil {
s.manifestErr = err
s.log.Warn("stage manifest unavailable", "dist", abs, "error", err)
} else if m, err := stage.LoadManifest(raw); err != nil {
s.manifestErr = err
s.log.Warn("stage manifest is not readable", "dist", abs, "error", err)
} else {
s.manifest = m
}
s.index, s.indexErr = search.Open(filepath.Join(abs, site.IndexFile))
if s.indexErr != nil {
s.log.Warn("search index unavailable", "dist", abs, "error", s.indexErr)
}
s.handler = s.routes()
return s, nil
}
// Handler is the whole site behind one http.Handler.
func (s *Server) Handler() http.Handler { return s.handler }
// Close releases the search index handle.
func (s *Server) Close() error {
if s.index != nil {
return s.index.Close()
}
return nil
}
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
// Liveness and readiness. The AI field is separate and deliberately false:
// Mode A never needs a model (docs/phase-2-static-site.md §2.1).
mux.HandleFunc("GET /healthz", s.handleHealth)
// The two things that are not pre-rendered files.
mux.HandleFunc("GET /search", s.handleSearch)
mux.HandleFunc("GET /api/stage/stream", s.handleStream)
// Route driver: one pre-rendered fragment per click.
mux.HandleFunc("GET /stage/{id}", s.handleFragment)
// Assets the build copied out of the repository.
mux.Handle("GET /static/", s.assetHandler("static"))
mux.Handle("GET /assets/", s.assetHandler("assets"))
// Every other path is a page. "GET /" is the fallback pattern.
mux.HandleFunc("GET /", s.handlePage)
return s.middleware(s.rejectDotSegments(mux))
}
// rejectDotSegments refuses a request whose path has a "." or ".." segment
// before http.ServeMux cleans it and answers with a redirect. Without this,
// "/static/../build.json" is redirected to "/build.json" and served from outside
// the asset directory. The check runs on URL.Path, which is already percent-
// decoded, so an encoded "%2e%2e" is caught as well.
func (s *Server) rejectDotSegments(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if hasDotSegment(r.URL.Path) {
s.notFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// hasDotSegment reports whether any segment of p is "." or "..".
func hasDotSegment(p string) bool {
for _, seg := range strings.Split(p, "/") {
if seg == "." || seg == ".." {
return true
}
}
return false
}
// middleware adds the security headers, one log line per request, and a panic
// guard that keeps one bad request from taking the process down.
func (s *Server) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// The policy mirrors the Caddy block in docs/phase-3-deploy.md, so the
// browser sees the same rules whether it reaches the origin or the edge.
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("X-Frame-Options", "DENY")
h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
rec := &statusWriter{ResponseWriter: w}
started := time.Now()
defer func() {
if p := recover(); p != nil {
s.log.Error("request panic", "method", r.Method, "path", r.URL.Path, "panic", fmt.Sprint(p))
if rec.status == 0 {
http.Error(rec, "internal error", http.StatusInternalServerError)
}
}
s.log.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.statusOf(),
"bytes", rec.bytes,
"duration_ms", time.Since(started).Milliseconds(),
)
}()
next.ServeHTTP(rec, r)
})
}
// handleHealth reports static and AI readiness separately. Static readiness is
// the only thing that decides the status code, because static readiness is what
// a deploy needs; the AI field says plainly that Mode A does not use one.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
indexReady := s.index != nil
documents := 0
if indexReady {
if n, err := s.index.Count(); err == nil {
documents = n
}
}
if documents == 0 && s.built != nil {
documents = s.built.Indexed
}
static := struct {
Ready bool `json:"ready"`
Pages int `json:"pages,omitempty"`
Built string `json:"built,omitempty"`
Error string `json:"error,omitempty"`
}{Ready: s.staticReady()}
if s.built != nil {
static.Pages = s.built.Pages
static.Built = s.built.Built.Format(time.RFC3339)
}
if !static.Ready && s.builtErr != nil {
static.Error = s.builtErr.Error()
} else if !static.Ready {
static.Error = "index.html is missing from the build directory"
}
body := struct {
Status string `json:"status"`
Static any `json:"static"`
AI struct {
Ready bool `json:"ready"`
Reason string `json:"reason"`
} `json:"ai"`
Search struct {
Ready bool `json:"ready"`
Documents int `json:"documents,omitempty"`
Error string `json:"error,omitempty"`
} `json:"search"`
}{Static: static}
body.AI.Ready = false
body.AI.Reason = "Mode A serves pre-rendered pages and calls no model; Mode B reports its own readiness in Phase 5."
body.Search.Ready = indexReady
body.Search.Documents = documents
if s.indexErr != nil {
body.Search.Error = s.indexErr.Error()
}
code := http.StatusOK
body.Status = "ok"
if !static.Ready {
code = http.StatusServiceUnavailable
body.Status = "static-unavailable"
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(body)
}
// staticReady is true when the build actually produced a landing page.
func (s *Server) staticReady() bool {
if s.built == nil {
return false
}
st, err := os.Stat(filepath.Join(s.dist, "index.html"))
return err == nil && !st.IsDir()
}
// handlePage serves a pre-rendered page by URL. Directory URLs map to
// index.html so the same tree works under any plain file server.
func (s *Server) handlePage(w http.ResponseWriter, r *http.Request) {
rel := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
file, ok := s.resolvePage(rel)
if !ok {
s.notFound(w, r)
return
}
if !s.serveFile(w, r, file, "text/html; charset=utf-8", pageCache) {
s.notFound(w, r)
}
}
// resolvePage maps a cleaned URL path onto a file inside the build directory.
// A file that is not there is not an error: the caller serves the 404 page.
func (s *Server) resolvePage(rel string) (string, bool) {
candidates := make([]string, 0, 3)
switch {
case rel == "" || strings.HasSuffix(rel, "/"):
candidates = append(candidates, rel+"index.html")
case path.Ext(rel) == "":
candidates = append(candidates, rel, rel+"/index.html", rel+".html")
default:
candidates = append(candidates, rel)
}
for _, c := range candidates {
full := filepath.Join(s.dist, filepath.FromSlash(c))
if !within(s.dist, full) {
continue
}
if st, err := os.Stat(full); err == nil && !st.IsDir() {
return full, true
}
}
return "", false
}
// handleFragment serves the route driver's payload: one pre-rendered stage
// fragment, selected by the evidence table's hx-get. It is the same bytes the
// stream driver sends as an event.
func (s *Server) handleFragment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !validFragmentID(id) {
s.fragmentNotFound(w, "That fragment is not part of this build.")
return
}
file := filepath.Join(s.dist, "stage", id+".html")
if !within(s.dist, file) {
s.fragmentNotFound(w, "That fragment is not part of this build.")
return
}
if _, err := os.Stat(file); err != nil {
s.fragmentNotFound(w, "That fragment is not part of this build.")
return
}
if !s.serveFile(w, r, file, "text/html; charset=utf-8", fragCache) {
s.fragmentNotFound(w, "That fragment could not be read.")
}
}
// validFragmentID keeps ids to the alphabet the build produces, so a request
// can never build a path of its own.
func validFragmentID(id string) bool {
if id == "" || len(id) > 128 {
return false
}
for _, r := range id {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
default:
return false
}
}
return true
}
// fragmentNotFound returns a small HTML fragment, because the panel swaps the
// response into the page and a full document would nest inside it.
func (s *Server) fragmentNotFound(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `<div class="stage-empty"><p class="stage-empty-title">`+msg+`</p><p class="stage-hint">The <a href="/evidence/">evidence index</a> lists every fragment in this build.</p></div>`)
}
// 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.
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
failed := false
indexed := 0
if s.built != nil {
indexed = s.built.Indexed
}
var results []search.Result
if s.index != nil {
if n, err := s.index.Count(); err == nil {
indexed = n
}
}
if q != "" {
if s.index == nil {
failed = true
} else {
var err error
results, err = s.index.Query(q, 20)
if err != nil {
failed = true
s.log.Warn("search failed", "query_length", len(q), "error", err)
}
}
}
view := s.shell.SearchView(indexed, q, results, failed)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Vary", "HX-Request")
var buf bytes.Buffer
var c interface {
Render(context.Context, io.Writer) error
} = templates.SearchPage(view)
if r.Header.Get("HX-Request") == "true" {
c = templates.SearchResults(view)
}
if err := c.Render(r.Context(), &buf); err != nil {
s.log.Error("render search", "error", err)
http.Error(w, "search is unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = w.Write(buf.Bytes())
}
}
// notFound serves the pre-rendered 404 page with a 404 status.
func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
file := filepath.Join(s.dist, "404.html")
if raw, err := os.ReadFile(file); err == nil && within(s.dist, file) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNotFound)
if r.Method != http.MethodHead {
_, _ = w.Write(raw)
}
return
}
http.Error(w, "not found", http.StatusNotFound)
}
// assetHandler serves a directory of build output (/static/, /assets/).
func (s *Server) assetHandler(dir string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
prefix := "/" + dir + "/"
rel := strings.TrimPrefix(path.Clean("/"+strings.TrimPrefix(r.URL.Path, prefix)), "/")
if rel == "" || strings.HasPrefix(rel, "..") {
s.notFound(w, r)
return
}
file := filepath.Join(s.dist, dir, filepath.FromSlash(rel))
if !within(filepath.Join(s.dist, dir), file) {
s.notFound(w, r)
return
}
ctype := contentTypeFor(file)
if !s.serveFile(w, r, file, ctype, assetCache) {
s.notFound(w, r)
}
})
}
// serveFile writes one file, with Range and HEAD support. It returns false when
// the file is missing or unreadable, so the caller can choose the error page.
func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, file, ctype, cache string) bool {
f, err := os.Open(file)
if err != nil {
return false
}
defer f.Close()
st, err := f.Stat()
if err != nil || st.IsDir() {
return false
}
h := w.Header()
h.Set("Content-Type", ctype)
h.Set("Cache-Control", cache)
h.Set("Content-Length", strconv.FormatInt(st.Size(), 10))
http.ServeContent(w, r, st.Name(), st.ModTime(), f)
return true
}
// within reports whether target is inside root, after cleaning both. Every path
// built from a request goes through here before it reaches the filesystem.
func within(root, target string) bool {
rel, err := filepath.Rel(root, target)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
}
// contentTypeFor is the small set of types this site publishes.
func contentTypeFor(file string) string {
switch strings.ToLower(filepath.Ext(file)) {
case ".css":
return "text/css; charset=utf-8"
case ".js":
return "text/javascript; charset=utf-8"
case ".svg":
return "image/svg+xml"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".webp":
return "image/webp"
case ".woff2":
return "font/woff2"
case ".json":
return "application/json; charset=utf-8"
case ".html":
return "text/html; charset=utf-8"
}
return "application/octet-stream"
}
// statusWriter records the status and size for the request log, and forwards
// Flush so an event stream is not buffered by the middleware.
type statusWriter struct {
http.ResponseWriter
status int
bytes int
}
func (w *statusWriter) WriteHeader(code int) {
if w.status == 0 {
w.status = code
}
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}
func (w *statusWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func (w *statusWriter) statusOf() int {
if w.status == 0 {
return http.StatusOK
}
return w.status
}

View File

@@ -0,0 +1,371 @@
package server
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/site"
)
// The tests run against a real build of the repository's content, because the
// thing under test is "does the server serve what the build wrote". One build is
// shared by every test.
var (
distDir string
buildErr error
)
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "ai-resume-server-test-")
if err != nil {
panic(err)
}
root, err := filepath.Abs("../..")
if err != nil {
panic(err)
}
s, err := site.New(site.Config{Root: root, OutDir: dir})
if err == nil {
err = s.Build()
}
buildErr, distDir = err, dir
code := m.Run()
_ = os.RemoveAll(dir)
os.Exit(code)
}
// newServer starts the site under test. Frames are sent as fast as possible so
// the stream test does not sleep.
func newServer(t *testing.T) *httptest.Server {
t.Helper()
if buildErr != nil {
t.Fatalf("build fixture: %v", buildErr)
}
s, err := New(Config{DistDir: distDir, StreamDelay: time.Millisecond})
if err != nil {
t.Fatalf("new server: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
ts := httptest.NewServer(s.Handler())
t.Cleanup(ts.Close)
return ts
}
func get(t *testing.T, ts *httptest.Server, path string, headers map[string]string) (int, http.Header, string) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil)
if err != nil {
t.Fatalf("request %s: %v", path, err)
}
for k, v := range headers {
req.Header.Set(k, v)
}
res, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("get %s: %v", path, err)
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return res.StatusCode, res.Header, string(body)
}
// TestHealthzSeparatesStaticAndAI is §2.1: readiness for static is its own
// field and does not depend on any AI service.
func TestHealthzSeparatesStaticAndAI(t *testing.T) {
ts := newServer(t)
code, header, body := get(t, ts, "/healthz", nil)
if code != http.StatusOK {
t.Fatalf("healthz status = %d, want 200; body: %s", code, body)
}
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("healthz content-type = %q, want JSON", ct)
}
var got struct {
Status string `json:"status"`
Static struct {
Ready bool `json:"ready"`
Pages int `json:"pages"`
} `json:"static"`
AI struct {
Ready bool `json:"ready"`
Reason string `json:"reason"`
} `json:"ai"`
Search struct {
Ready bool `json:"ready"`
Documents int `json:"documents"`
} `json:"search"`
}
if err := json.Unmarshal([]byte(body), &got); err != nil {
t.Fatalf("healthz is not JSON: %v\n%s", err, body)
}
if !got.Static.Ready {
t.Error("static.ready = false, want true")
}
if got.Static.Pages == 0 {
t.Error("static.pages = 0, want the built page count")
}
if got.AI.Ready {
t.Error("ai.ready = true, want false in Mode A")
}
if got.AI.Reason == "" {
t.Error("ai.reason is empty; the field must say why AI is not ready")
}
if !got.Search.Ready || got.Search.Documents == 0 {
t.Errorf("search = %+v, want ready with documents", got.Search)
}
}
// TestPagesArePreRendered checks that the two page kinds render from disk with
// the chrome, the nav and the panel.
func TestPagesArePreRendered(t *testing.T) {
ts := newServer(t)
cases := []struct {
path string
wants []string
}{
{"/", []string{"Sam Rolfe", `data-stage-driver="route"`, `id="stage-content"`, "Presentation panel", "Skip to content"}},
{"/edge/", []string{"Edge", `data-stage-driver="route"`, "Breadcrumb"}},
{"/evidence/", []string{"Evidence index", `class="evidence"`}},
{"/stream/", []string{`data-stage-driver="stream"`, `sse-connect="/api/stage/stream"`, `sse-swap="stage"`, "/static/sse.js"}},
}
for _, tc := range cases {
code, header, body := get(t, ts, tc.path, nil)
if code != http.StatusOK {
t.Errorf("%s: status %d, want 200", tc.path, code)
continue
}
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("%s: content-type %q", tc.path, ct)
}
for _, want := range tc.wants {
if !strings.Contains(body, want) {
t.Errorf("%s: body does not contain %q", tc.path, want)
}
}
}
}
// TestModeADoesNotShipTheStreamExtension keeps the two modes honestly separate:
// a page only loads htmx's SSE extension when its panel is stream-driven.
func TestModeADoesNotShipTheStreamExtension(t *testing.T) {
ts := newServer(t)
for _, path := range []string{"/", "/edge/", "/evidence/"} {
if _, _, body := get(t, ts, path, nil); strings.Contains(body, "sse.js") {
t.Errorf("%s loads sse.js, but it is not a stream page", path)
}
}
}
// TestRouteDriverFragment is §2.4's first half: a click loads one pre-rendered
// fragment, and it is a fragment, not a page.
func TestRouteDriverFragment(t *testing.T) {
ts := newServer(t)
code, header, body := get(t, ts, "/stage/doc-edge-overview", nil)
if code != http.StatusOK {
t.Fatalf("fragment status = %d, want 200", code)
}
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("fragment content-type = %q", ct)
}
if !strings.Contains(body, `class="frag frag-`) {
t.Errorf("fragment body does not look like a fragment: %.120s", body)
}
if strings.Contains(body, "<!DOCTYPE html>") || strings.Contains(body, "<html") {
t.Error("fragment response is a whole document; the panel would nest a page")
}
for _, bad := range []string{"/stage/../../etc/passwd", "/stage/", "/stage/..%2f..%2fetc%2fpasswd", "/stage/does-not-exist"} {
if code, _, _ := get(t, ts, bad, nil); code == http.StatusOK {
t.Errorf("%s: status 200, want a 404 fragment", bad)
}
}
}
// TestStreamDriverSendsFragments is §2.4's second half: the same fragments,
// pushed over SSE, with no layout change.
func TestStreamDriverSendsFragments(t *testing.T) {
ts := newServer(t)
code, header, body := get(t, ts, "/api/stage/stream?k=doc-edge-overview", nil)
if code != http.StatusOK {
t.Fatalf("stream status = %d, want 200", code)
}
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("stream content-type = %q, want text/event-stream", ct)
}
if got := header.Get("Cache-Control"); !strings.Contains(got, "no-store") {
t.Errorf("stream cache-control = %q, want no-store", got)
}
if !strings.Contains(body, "event: "+StreamEvent) {
t.Errorf("stream has no %q event: %.200s", StreamEvent, body)
}
if got := strings.Count(body, "event: "+StreamEvent); got != 1 {
t.Errorf("stream sent %d events for one key, want 1", got)
}
// Exactly one article per frame: the build rendered it, the server must not
// wrap it again.
if got := strings.Count(body, `<article class="frag`); got != 1 {
t.Errorf("frame contains %d fragment articles, want 1", got)
}
if !strings.Contains(body, "data: ") {
t.Error("stream frame has no data line")
}
}
// TestStreamDefaultIsSixAreaFragments keeps the demonstration's default stream
// honest: the six area landing fragments, in navigation order, and no more.
func TestStreamDefaultIsSixAreaFragments(t *testing.T) {
ts := newServer(t)
code, _, body := get(t, ts, "/api/stage/stream", nil)
if code != http.StatusOK {
t.Fatalf("stream status = %d, want 200", code)
}
if got := strings.Count(body, "event: "+StreamEvent); got != 6 {
t.Errorf("default stream has %d frames, want the six areas", got)
}
// Titles are HTML-escaped in the frame, so match the distinctive word.
for _, area := range []string{"Agentic", "Platform", "Edge", "Products", "History", "About"} {
if !strings.Contains(body, area) {
t.Errorf("default stream is missing the %s area", area)
}
}
}
// TestSearchWorksWithAndWithoutHTMX covers §2.5 and §2.6: the same handler
// returns a page for a form post and a fragment for HTMX.
func TestSearchWorksWithAndWithoutHTMX(t *testing.T) {
ts := newServer(t)
for _, tc := range []struct {
q string
want string
}{
{"zigbee", "/edge/"},
{"signage", "/products/"},
{"routing", "/ai/"},
} {
code, _, body := get(t, ts, "/search?q="+tc.q, nil)
if code != http.StatusOK {
t.Errorf("search %q: status %d", tc.q, code)
continue
}
// templ writes a lowercase HTML5 doctype, so compare case-insensitively.
if !strings.Contains(strings.ToLower(body), "<!doctype html>") {
t.Errorf("search %q without HTMX did not return a page", tc.q)
}
if !strings.Contains(body, tc.want) {
t.Errorf("search %q has no result in %s", tc.q, tc.want)
}
code, _, frag := get(t, ts, "/search?q="+tc.q, map[string]string{"HX-Request": "true"})
if code != http.StatusOK {
t.Errorf("htmx search %q: status %d", tc.q, code)
continue
}
if strings.Contains(frag, "<html") {
t.Errorf("htmx search %q returned a whole page, want the results block", tc.q)
}
if !strings.Contains(frag, `id="search-results"`) {
t.Errorf("htmx search %q did not return #search-results", tc.q)
}
}
}
// TestSearchRejectsOperators checks that FTS5 syntax in a visitor's input is
// never honoured, so a query can only be words.
func TestSearchRejectsOperators(t *testing.T) {
ts := newServer(t)
for _, q := range []string{`" OR "`, `zigbee" OR body:`, `NEAR(zigbee routing)`, `*`} {
code, _, body := get(t, ts, "/search?q="+strings.ReplaceAll(q, " ", "+"), nil)
if code != http.StatusOK {
t.Errorf("query %q: status %d, want 200 with an honest empty/refused result", q, code)
}
if strings.Contains(body, "could not be run") && q == "*" {
t.Logf("query %q reported as unparseable, which is acceptable", q)
}
}
}
// TestUnknownPathIsTheBuilt404Page makes sure a typo lands on the pre-rendered
// page rather than a bare error, and that the status stays 404.
func TestUnknownPathIsTheBuilt404Page(t *testing.T) {
ts := newServer(t)
code, _, body := get(t, ts, "/does-not-exist", nil)
if code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", code)
}
if !strings.Contains(body, "That page is not here") {
t.Error("404 body is not the built 404 page")
}
}
// TestStaticAssetsAreServedWithTheirType keeps the stylesheet, htmx and the SSE
// extension reachable, and confirms content sniffing is refused.
func TestStaticAssetsAreServedWithTheirType(t *testing.T) {
ts := newServer(t)
for _, tc := range []struct{ path, ctype string }{
{"/static/site.css", "text/css"},
{"/static/chroma.css", "text/css"},
{"/static/htmx.min.js", "text/javascript"},
{"/static/sse.js", "text/javascript"},
} {
code, header, _ := get(t, ts, tc.path, nil)
if code != http.StatusOK {
t.Errorf("%s: status %d, want 200", tc.path, code)
continue
}
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, tc.ctype) {
t.Errorf("%s: content-type %q, want %q", tc.path, ct, tc.ctype)
}
if header.Get("X-Content-Type-Options") != "nosniff" {
t.Errorf("%s: missing nosniff", tc.path)
}
}
for _, bad := range []string{"/static/../build.json", "/static/%2e%2e/build.json", "/assets/../dist"} {
if code, _, _ := get(t, ts, bad, nil); code == http.StatusOK {
t.Errorf("%s: status 200, want a rejection", bad)
}
}
}
// TestSecurityHeadersAreAlwaysPresent mirrors the phase 3 edge rules at the
// origin, so a direct request sees the same policy as one through Caddy.
func TestSecurityHeadersAreAlwaysPresent(t *testing.T) {
ts := newServer(t)
for _, path := range []string{"/", "/search?q=zigbee", "/healthz", "/does-not-exist"} {
_, header, _ := get(t, ts, path, nil)
if header.Get("Content-Security-Policy") == "" {
t.Errorf("%s: no CSP", path)
}
if header.Get("Referrer-Policy") == "" {
t.Errorf("%s: no referrer policy", path)
}
if header.Get("X-Frame-Options") == "" {
t.Errorf("%s: no frame options", path)
}
}
}
// TestFirstByteBudget is a coarse A2 check: on loopback, well inside the 100 ms
// budget the specification sets for a page served over the LAN.
func TestFirstByteBudget(t *testing.T) {
ts := newServer(t)
started := time.Now()
code, _, _ := get(t, ts, "/", nil)
elapsed := time.Since(started)
if code != http.StatusOK {
t.Fatalf("status = %d", code)
}
if elapsed > 100*time.Millisecond {
t.Errorf("first byte in %s, budget is 100ms", elapsed)
}
}

188
internal/server/stream.go Normal file
View File

@@ -0,0 +1,188 @@
package server
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
)
// StreamEvent is the SSE event the panel listens for. The binding rendered by
// stage.SSEDriver uses the same name, so a client needs no configuration.
const StreamEvent = "stage"
// maxStreamKeys caps how many fragments one connection may ask for, so a
// crafted query cannot turn the demonstration into a long-lived loop.
const maxStreamKeys = 24
// handleStream points the stage panel at an event stream.
//
// This is the Phase 2 proof of the §2.4 contract: the panel markup does not
// change, only the binding does. The frames come from the fragments the build
// already rendered, so nothing calls a model and the site still works with
// every AI service stopped. Phase 5 replaces the source behind the same
// interface (stage.Source) and this handler does not change.
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming is not supported by this server", http.StatusInternalServerError)
return
}
if s.manifest == nil || len(s.manifest.Entries) == 0 {
msg := "the stage manifest is not part of this build"
if s.manifestErr != nil {
s.log.Warn("stream unavailable", "error", s.manifestErr)
}
http.Error(w, msg, http.StatusServiceUnavailable)
return
}
keys := s.streamKeys(r.URL.Query())
if len(keys) == 0 {
keys = s.defaultStreamKeys()
}
if len(keys) == 0 {
http.Error(w, "no fragments to stream", http.StatusNotFound)
return
}
h := w.Header()
h.Set("Content-Type", "text/event-stream; charset=utf-8")
h.Set("Cache-Control", "no-store")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
// A client that reconnects gets a short pause rather than an immediate retry.
if _, err := io.WriteString(w, "retry: 2000\n\n"); err != nil {
return
}
flusher.Flush()
ctx := r.Context()
source := s.newStreamSource(ctx)
err := source.Stream(ctx, keys, func(f stage.Fragment) error {
var buf bytes.Buffer
if f.Rendered {
// The build already rendered the whole article; send those bytes.
if err := f.Body.Render(ctx, &buf); err != nil {
return err
}
} else {
// A structured source (Phase 5) hands over parts, so the panel
// renders them here with the same component the route driver uses.
if err := templates.FragmentView(f).Render(ctx, &buf); err != nil {
return err
}
}
if err := writeSSE(w, StreamEvent, buf.String()); err != nil {
return err
}
flusher.Flush()
return nil
})
if err != nil && ctx.Err() == nil {
// Headers are already sent, so there is no status to change. Log it and
// let the panel keep the last fragment it received.
s.log.Warn("stage stream ended early", "error", err)
}
}
// streamKeys reads the fragment ids a caller asked for. Both `?k=a&k=b` and
// `?k=a,b` work, because both are natural to write by hand.
func (s *Server) streamKeys(q url.Values) []string {
var keys []string
for _, raw := range q["k"] {
for _, part := range strings.Split(raw, ",") {
id := strings.TrimSpace(part)
if id != "" && validFragmentID(id) {
keys = append(keys, id)
}
if len(keys) >= maxStreamKeys {
return keys
}
}
}
return keys
}
// defaultStreamKeys is the demonstration stream: the six area landing
// fragments, in navigation order. The build writes those first, so the manifest
// alone is enough — the server never reads the content tree.
func (s *Server) defaultStreamKeys() []string {
if s.manifest == nil {
return nil
}
keys := make([]string, 0, 6)
for _, e := range s.manifest.Entries {
if !strings.HasPrefix(e.ID, "doc-") {
continue
}
keys = append(keys, e.ID)
if len(keys) == 6 {
break
}
}
if len(keys) > 0 {
return keys
}
for _, e := range s.manifest.Entries {
keys = append(keys, e.ID)
if len(keys) == 6 {
break
}
}
return keys
}
// newStreamSource wires the manifest to the files on disk and paces the frames.
func (s *Server) newStreamSource(ctx context.Context) *stage.ManifestSource {
read := func(e stage.Entry) (string, error) {
file := filepath.Join(s.dist, filepath.FromSlash(e.File))
if !within(s.dist, file) {
return "", fmt.Errorf("fragment %s is outside the build directory", e.ID)
}
raw, err := os.ReadFile(file)
if err != nil {
return "", fmt.Errorf("fragment %s: %w", e.ID, err)
}
return string(raw), nil
}
delay := s.cfg.StreamDelay
frame := func(i int) {
if i == 0 || delay <= 0 {
return
}
select {
case <-time.After(delay):
case <-ctx.Done():
}
}
return stage.NewManifestSource(s.manifest, read, frame)
}
// writeSSE writes one event. A payload with newlines is split across data
// lines, which is what the server-sent events format expects.
func writeSSE(w io.Writer, event, data string) error {
var b strings.Builder
b.WriteString("event: ")
b.WriteString(event)
b.WriteByte('\n')
for _, line := range strings.Split(data, "\n") {
b.WriteString("data: ")
b.WriteString(line)
b.WriteByte('\n')
}
b.WriteByte('\n')
_, err := io.WriteString(w, b.String())
return err
}

318
internal/site/build.go Normal file
View File

@@ -0,0 +1,318 @@
package site
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"github.com/a-h/templ"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
)
// Published file names. The server and the build agree on these, and nothing
// else does.
const (
// IndexFile is the SQLite FTS5 index, read-only at request time.
IndexFile = "search.db"
// ManifestFile is the stage fragment index.
ManifestFile = "stage/manifest.json"
// BuildInfoFile records what the last build produced.
BuildInfoFile = "build.json"
// markerFile marks a directory as a previous build, so Build can clean it
// without ever deleting a directory that was not ours.
markerFile = ".build"
)
// streamEndpoint is where the streaming panel connects.
const streamEndpoint = "/api/stage/stream"
// BuildInfo is what the build wrote, for /healthz and for the operator.
type BuildInfo struct {
Built time.Time `json:"built"`
Documents int `json:"documents"`
Pages int `json:"pages"`
Fragments int `json:"fragments"`
Indexed int `json:"indexed"`
Artefacts int `json:"artefacts"`
}
// LoadBuildInfo reads build.json from a published directory.
func LoadBuildInfo(dir string) (*BuildInfo, error) {
raw, err := os.ReadFile(filepath.Join(dir, BuildInfoFile))
if err != nil {
return nil, err
}
var info BuildInfo
if err := json.Unmarshal(raw, &info); err != nil {
return nil, fmt.Errorf("%s: %w", BuildInfoFile, err)
}
return &info, nil
}
// Build pre-renders the whole site into OutDir and makes no network call.
//
// It writes: the landing page, every published document at its own URL, the
// evidence index, the search page shell, the streaming panel page, the 404,
// one file per stage fragment plus the manifest, the FTS5 index, the content
// assets and the static files. Nothing is rendered at request time except a
// search with a query in it.
func (s *Site) Build() error {
started := time.Now()
if err := s.prepareOut(); err != nil {
return err
}
pages := 0
writePage := func(url string, c templ.Component) error {
pages++
return renderTo(s.filePathForURL(url), c)
}
if err := writePage("/", templates.HomePage(s.Home())); err != nil {
return err
}
for _, d := range s.PublicDocs() {
if err := writePage(d.URL, templates.DocPage(s.Doc(d))); err != nil {
return fmt.Errorf("%s: %w", d.Path, err)
}
}
if err := writePage("/evidence/", templates.EvidencePage(s.Evidence())); err != nil {
return err
}
if err := writePage("/search", templates.SearchPage(s.SearchView(len(s.PublicDocs()), "", nil, false))); err != nil {
return err
}
if err := writePage("/stream/", templates.StreamPage(s.Stream(streamEndpoint))); err != nil {
return err
}
if err := writePage("/404.html", templates.NotFoundPage(s.NotFoundPage())); err != nil {
return err
}
for _, f := range s.Fragments {
if err := writeFile(filepath.Join(s.Config.OutDir, filepath.FromSlash(f.File)), []byte(f.HTML)); err != nil {
return fmt.Errorf("fragment %s: %w", f.ID, err)
}
}
manifest, err := s.Manifest.Marshal()
if err != nil {
return err
}
if err := writeFile(filepath.Join(s.Config.OutDir, ManifestFile), manifest); err != nil {
return err
}
indexed, err := s.buildIndex()
if err != nil {
return err
}
if err := s.copyAssets(); err != nil {
return err
}
if err := s.copyStatic(); err != nil {
return err
}
info := BuildInfo{
Built: started.UTC(),
Documents: len(s.PublicDocs()),
Pages: pages,
Fragments: len(s.Fragments),
Indexed: indexed,
Artefacts: s.artefactCount(),
}
raw, err := json.MarshalIndent(info, "", " ")
if err != nil {
return err
}
if err := writeFile(filepath.Join(s.Config.OutDir, BuildInfoFile), append(raw, '\n')); err != nil {
return err
}
return writeFile(filepath.Join(s.Config.OutDir, markerFile), []byte("ai-resume build output\n"))
}
// filePathForURL maps a site URL onto its file. Directory URLs get index.html
// so the same tree works on any plain file server as well as this binary.
func (s *Site) filePathForURL(url string) string {
rel := strings.TrimPrefix(url, "/")
if rel == "" || strings.HasSuffix(rel, "/") {
rel += "index.html"
} else if path.Ext(rel) == "" {
rel += "/index.html"
}
return filepath.Join(s.Config.OutDir, filepath.FromSlash(rel))
}
// prepareOut empties the output directory, but only if it is a directory this
// build wrote before. There is no path by which a mistyped OutDir can delete a
// directory that belongs to something else.
func (s *Site) prepareOut() error {
abs, err := filepath.Abs(s.Config.OutDir)
if err != nil {
return err
}
if abs == string(filepath.Separator) || abs == filepath.Dir(abs) {
return fmt.Errorf("refusing to build into %q", abs)
}
root, err := filepath.Abs(s.Config.Root)
if err != nil {
return err
}
if abs == root {
return fmt.Errorf("refusing to build over the repository root %q", abs)
}
if st, err := os.Stat(abs); err == nil && st.IsDir() {
entries, err := os.ReadDir(abs)
if err != nil {
return err
}
if len(entries) > 0 {
if _, err := os.Stat(filepath.Join(abs, markerFile)); err != nil {
return fmt.Errorf("refusing to clean %s: it is not a previous build (no %s)", abs, markerFile)
}
}
}
if err := os.RemoveAll(abs); err != nil {
return err
}
return os.MkdirAll(abs, 0o755)
}
// buildIndex writes the FTS5 index and returns how many pages it covers.
func (s *Site) buildIndex() (int, error) {
docs := make([]search.Doc, 0, len(s.Content.Docs))
for _, d := range s.PublicDocs() {
docs = append(docs, search.Doc{
ID: d.ID,
URL: d.URL,
Title: d.Title,
Area: d.Area,
Level: d.Level,
Confidence: d.Confidence,
Body: indexBody(d),
})
}
file := filepath.Join(s.Config.OutDir, IndexFile)
if err := search.Build(file, docs); err != nil {
return 0, err
}
return len(docs), nil
}
// indexBody is what FTS5 searches: the claim, the rendered body reduced to
// text, and the technology and tag vocabularies so "zigbee" finds a page that
// names it only in front-matter.
func indexBody(d *content.Document) string {
parts := []string{d.Claim, d.Plain}
parts = append(parts, d.Tech...)
parts = append(parts, d.Tags...)
return strings.Join(parts, " ")
}
// copyAssets publishes content/assets at /assets/.
func (s *Site) copyAssets() error {
src := filepath.Join(s.Config.ContentDir, "assets")
dst := filepath.Join(s.Config.OutDir, "assets")
if _, err := os.Stat(src); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return filepath.WalkDir(src, func(p string, e fs.DirEntry, err error) error {
if err != nil {
return err
}
name := e.Name()
if strings.HasPrefix(name, ".") {
if e.IsDir() && p != src {
return filepath.SkipDir
}
return nil
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if e.IsDir() {
return os.MkdirAll(target, 0o755)
}
raw, err := os.ReadFile(p)
if err != nil {
return err
}
return writeFile(target, raw)
})
}
// copyStatic publishes the vendored htmx, the SSE extension and the site's own
// stylesheet, plus the chroma stylesheet generated from the renderer's style so
// the highlighted code and the page agree on colour.
func (s *Site) copyStatic() error {
dst := filepath.Join(s.Config.OutDir, "static")
err := fs.WalkDir(web.Static, "static", func(p string, e fs.DirEntry, err error) error {
if err != nil {
return err
}
if e.IsDir() {
return nil
}
raw, err := web.Static.ReadFile(p)
if err != nil {
return err
}
return writeFile(filepath.Join(dst, filepath.FromSlash(strings.TrimPrefix(p, "static/"))), raw)
})
if err != nil {
return err
}
chroma, err := s.Renderer.ChromaCSS()
if err != nil {
return err
}
return writeFile(filepath.Join(dst, "chroma.css"), []byte(chroma))
}
// renderTo renders a templ component to a file, creating its directory.
func renderTo(file string, c templ.Component) error {
var buf bytes.Buffer
if err := c.Render(context.Background(), &buf); err != nil {
return err
}
return writeFile(file, buf.Bytes())
}
// writeFile writes atomically, so a failed build never leaves half a page.
func writeFile(file string, raw []byte) error {
if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
return err
}
tmp := file + ".tmp"
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
return err
}
return os.Rename(tmp, file)
}
// PublishedPages lists the URLs this site publishes, for tests and for the
// operator running the build. Order is stable.
func (s *Site) PublishedPages() []string {
urls := []string{"/", "/evidence/", "/search", "/stream/", "/404.html"}
for _, d := range s.PublicDocs() {
urls = append(urls, d.URL)
}
sort.Strings(urls)
return urls
}

200
internal/site/fragments.go Normal file
View File

@@ -0,0 +1,200 @@
package site
import (
"bytes"
"context"
"fmt"
"os"
"path"
"github.com/a-h/templ"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
)
// buildFragments renders every stage fragment once, in manifest order:
//
// 1. every document's own fragment, L1 first in area order
// 2. every artefact fragment, in the same document order
//
// The order matters: the first len(content.Areas) entries are the six area
// landing fragments, which is the default stream, and the server reads that
// order out of the manifest without loading the content tree. The panel is a
// content sink, so this is the only place that decides what a fragment is.
// Neither driver renders anything itself.
func (s *Site) buildFragments() error {
order := make([]*content.Document, 0, len(s.Content.Docs))
for _, a := range content.Areas {
for _, d := range s.PublicInArea(a.Key) {
if d.Level == 1 {
order = append(order, d)
}
}
}
rest := make([]*content.Document, 0, len(s.Content.Docs))
for _, a := range content.Areas {
for _, d := range s.PublicInArea(a.Key) {
if d.Level != 1 {
rest = append(rest, d)
}
}
}
order = append(order, rest...)
for _, d := range order {
if err := s.addFragment(s.docFragment(d)); err != nil {
return err
}
}
for _, d := range order {
for i, a := range d.Artefacts {
f, err := s.artefactFragment(d, a, stage.ArtefactFragmentID(d.ID, i))
if err != nil {
return err
}
if err := s.addFragment(f); err != nil {
return err
}
}
}
return nil
}
func (s *Site) addFragment(f *Fragment) error {
if _, dup := s.fragByID[f.ID]; dup {
return fmt.Errorf("stage fragment id %q is not unique", f.ID)
}
html, err := renderFragment(f.component())
if err != nil {
return fmt.Errorf("render stage fragment %s: %w", f.ID, err)
}
f.HTML = html
s.Fragments = append(s.Fragments, f)
s.fragByID[f.ID] = f
s.FragmentsByDoc[f.DocID] = append(s.FragmentsByDoc[f.DocID], f.ID)
s.Manifest.Entries = append(s.Manifest.Entries, stage.Entry{
ID: f.ID,
DocID: f.DocID,
Title: f.Title,
Kind: f.Kind,
File: f.File,
})
return nil
}
// fragment builds the stage value this fragment renders from.
func (f *Fragment) component() stage.Fragment {
out := stage.Fragment{
ID: f.ID,
Kind: f.Kind,
Title: f.Title,
}
out.Subtitle = f.subtitle
out.Body = f.body
out.Code = f.code
out.Src = f.src
out.Alt = f.alt
out.Links = f.links
out.Confidence = f.confidence
out.DocURL = f.docURL
out.DocTitle = f.docTitle
return out
}
func renderFragment(f stage.Fragment) (string, error) {
var buf bytes.Buffer
if err := templates.FragmentView(f).Render(context.Background(), &buf); err != nil {
return "", err
}
return buf.String(), nil
}
// docFragment is the document's own body, shown in the panel when a reader
// asks for the page itself rather than one of its artefacts.
func (s *Site) docFragment(d *content.Document) *Fragment {
area, _ := content.AreaByKey(d.Area)
return &Fragment{
ID: stage.DocFragmentID(d.ID),
DocID: d.ID,
Kind: stage.KindText,
Title: d.Title,
subtitle: area.Title + " · " + templates.LevelLabel(d.Level),
body: templ.Raw(d.HTML),
confidence: d.Confidence,
docURL: d.URL,
docTitle: "Read the page",
File: "stage/" + stage.DocFragmentID(d.ID) + ".html",
}
}
// artefactFragment renders one artefact into the kind the panel understands.
func (s *Site) artefactFragment(d *content.Document, a content.Artefact, id string) (*Fragment, error) {
ra, err := s.Content.Resolve(d, a)
if err != nil {
return nil, err
}
f := &Fragment{
ID: id,
DocID: d.ID,
Kind: ra.Kind,
Title: a.Label,
ArtefactURL: a.URL,
confidence: d.Confidence,
File: "stage/" + id + ".html",
}
if ra.Target != nil {
f.subtitle = ra.Target.Title
f.body = templ.Raw(ra.Target.HTML)
f.confidence = ra.Target.Confidence
f.docURL = ra.Target.URL
f.docTitle = "Open " + ra.Target.Title
return f, nil
}
f.subtitle = ra.Rel
switch ra.Kind {
case stage.KindCode:
src, err := os.ReadFile(ra.File)
if err != nil {
return nil, fmt.Errorf("%s: read code artefact: %w", d.Path, err)
}
var buf bytes.Buffer
lang := content.LanguageFor(ra.File)
if err := s.Renderer.Highlight(&buf, lang, src); err != nil {
return nil, fmt.Errorf("%s: highlight artefact: %w", d.Path, err)
}
f.code = &stage.Code{Language: lang, Label: path.Base(ra.Rel), HTML: templ.Raw(buf.String())}
case stage.KindImage, stage.KindDiagram, stage.KindVideo:
f.src = ra.Href
f.alt = a.Label
default:
f.links = []stage.Link{{Label: a.Label, Href: ra.Href, Note: ra.Rel}}
}
return f, nil
}
// fragForEntry looks up a build-time fragment by manifest entry.
func (s *Site) fragForEntry(e stage.Entry) (*Fragment, bool) {
f, ok := s.fragByID[e.ID]
return f, ok
}
// FragmentHTML returns the pre-rendered markup for a fragment id. Both drivers
// serve these bytes: the route driver as a file, the stream driver as an event.
func (s *Site) FragmentHTML(id string) (string, bool) {
f, ok := s.fragByID[id]
if !ok {
return "", false
}
return f.HTML, true
}
// FragmentsByID is a copy of the fragment index, for tests and the manifest.
func (s *Site) FragmentsByID() map[string]*Fragment {
out := make(map[string]*Fragment, len(s.fragByID))
for k, v := range s.fragByID {
out[k] = v
}
return out
}

509
internal/site/site.go Normal file
View File

@@ -0,0 +1,509 @@
// Package site assembles the Mode A static site out of content/ and knows how
// to write it to disk.
//
// One assembler, two callers: cmd/build writes the whole site to a directory,
// and cmd/server uses the same view models for the two things that cannot be
// pre-rendered — a keyword search with a query in it, and the streamed stage
// demonstration. Everything else in the running server is a file on disk, so a
// page is never rendered per request and no page needs an AI service.
package site
import (
"path/filepath"
"time"
"github.com/a-h/templ"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
)
// Config is where the site is read from and written to.
type Config struct {
// Root is the repository root. Static assets are read from it.
Root string
// ContentDir is the Markdown source of truth. Defaults to Root/content.
ContentDir string
// OutDir is where Build writes the pre-rendered site. Defaults to Root/dist.
OutDir string
}
func (c *Config) defaults() {
if c.Root == "" {
c.Root = "."
}
if c.ContentDir == "" {
c.ContentDir = filepath.Join(c.Root, "content")
}
if c.OutDir == "" {
c.OutDir = filepath.Join(c.Root, "dist")
}
}
// Shell is the chrome every page shares. It needs no content tree, so the
// running server can render a search page with only dist/ on disk.
type Shell struct {
// Year is stamped in the footer.
Year int
}
// NewShell returns the chrome for the current year.
func NewShell() Shell { return Shell{Year: year()} }
// Nav builds the six-area navigation, marking area as current.
func (Shell) Nav(area string) []templates.NavItem {
items := make([]templates.NavItem, 0, len(content.Areas))
for _, a := range content.Areas {
items = append(items, templates.NavItem{
Title: a.Title,
Href: "/" + a.Key + "/",
Gutter: a.Gutter,
Current: a.Key == area,
})
}
return items
}
// RouteBinding is the Mode A driver: one pre-rendered fragment per click.
func (Shell) RouteBinding() stage.Binding {
return stage.RouteDriver{BaseURL: "/stage"}.Bind()
}
// StreamBinding is the Mode B driver: fragments pushed over SSE.
func (Shell) StreamBinding(endpoint string) stage.Binding {
return stage.SSEDriver{Endpoint: endpoint}.Bind()
}
// LayoutOpts are the per-page chrome values.
type LayoutOpts struct {
Title string
Description string
Area string
Crumbs []templates.Crumb
Binding stage.Binding
}
// Layout builds the chrome for one page. It never looks at the stage driver
// beyond handing the binding to the template.
func (sh Shell) Layout(o LayoutOpts) templates.LayoutView {
return templates.LayoutView{
Title: o.Title,
Description: o.Description,
Area: o.Area,
Nav: sh.Nav(o.Area),
Crumbs: o.Crumbs,
Stage: o.Binding,
Year: sh.Year,
}
}
// SearchView builds the search page. Search is the one Mode A page that reads a
// query, so the server renders it; with JavaScript off the form posts and the
// same page comes back with results.
func (sh Shell) SearchView(indexed int, q string, results []search.Result, failed bool) templates.SearchView {
return templates.SearchView{
Layout: sh.Layout(LayoutOpts{
Title: "Search — Sam Rolfe",
Description: "Keyword search across every page of this site. No model, no network call.",
Crumbs: []templates.Crumb{{Label: "Home", Href: "/"}, {Label: "Search"}},
Binding: sh.RouteBinding(),
}),
Query: q,
Results: results,
Indexed: indexed,
Failed: failed,
}
}
// NotFoundPage is the 404, pre-rendered at build time and also served live.
func (sh Shell) NotFoundPage() templates.NotFoundView {
return templates.NotFoundView{
Layout: sh.Layout(LayoutOpts{
Title: "Page not found — Sam Rolfe",
Description: "That page is not here. The six areas and the evidence index are the reliable ways in.",
Crumbs: []templates.Crumb{{Label: "Home", Href: "/"}, {Label: "Not found"}},
Binding: sh.RouteBinding(),
}),
}
}
// Site is the content tree plus everything derived from it.
type Site struct {
Shell
Config Config
Content *content.Set
Renderer *content.Renderer
// Fragments are the pre-rendered stage fragments, in manifest order. The
// first len(content.Areas) are the six area landing pages, which is the
// default stream.
Fragments []*Fragment
// Manifest indexes the same fragments the way the server sees them.
Manifest *stage.Manifest
// FragmentsByDoc lists fragment ids per document, in reading order.
FragmentsByDoc map[string][]string
fragByID map[string]*Fragment
}
// New loads content/ and renders every page and stage fragment in memory. It
// touches no network and starts no service.
func New(cfg Config) (*Site, error) {
cfg.defaults()
set, err := content.Load(cfg.ContentDir)
if err != nil {
return nil, err
}
r := content.NewRenderer(set)
if err := r.Render(); err != nil {
return nil, err
}
s := &Site{
Shell: NewShell(),
Config: cfg,
Content: set,
Renderer: r,
Manifest: &stage.Manifest{},
FragmentsByDoc: map[string][]string{},
fragByID: map[string]*Fragment{},
}
if err := s.buildFragments(); err != nil {
return nil, err
}
return s, nil
}
// PublicDocs returns the published documents: public: true, in navigation order.
func (s *Site) PublicDocs() []*content.Document {
out := make([]*content.Document, 0, len(s.Content.Docs))
for _, d := range s.Content.Docs {
if d.Public {
out = append(out, d)
}
}
return out
}
// PublicInArea returns the published documents of one area, L1 first.
func (s *Site) PublicInArea(area string) []*content.Document {
var out []*content.Document
for _, d := range s.PublicDocs() {
if d.Area == area {
out = append(out, d)
}
}
return out
}
// pageCount counts published pages in an area.
func (s *Site) pageCount(area string) int { return len(s.PublicInArea(area)) }
// artefactCount counts every resolved artefact on the site.
func (s *Site) artefactCount() int {
n := 0
for _, d := range s.PublicDocs() {
n += len(d.Artefacts)
}
return n
}
// Home is the landing page view: the ninety-second read.
func (s *Site) Home() templates.HomeView {
cardArea := func(a content.Area) templates.PillarCardView {
card := templates.PillarCardView{
Area: a,
Title: a.Title,
Claim: a.Blurb,
Href: "/" + a.Key + "/",
Level: 1,
Pages: s.pageCount(a.Key),
Metrics: []templates.MetricView{},
Tech: []string{},
}
if d, ok := s.Content.AreaLanding(a.Key); ok {
card.Claim = d.Claim
card.Confidence = d.Confidence
card.Title = d.Title
for _, m := range d.Metrics {
card.Metrics = append(card.Metrics, templates.MetricView{Label: m.Label, Value: content.MetricValue(m.Value)})
}
card.Tech = d.Tech
}
return card
}
cards := make([]templates.PillarCardView, 0, len(content.Areas))
for _, a := range content.Areas {
cards = append(cards, cardArea(a))
}
return templates.HomeView{
Layout: s.Layout(LayoutOpts{
Title: "Sam Rolfe — AI systems, the platforms under them, the hardware at the edge",
Description: content.Positioning + " " + content.SupportLine,
Crumbs: []templates.Crumb{{Label: "Home"}},
Binding: s.RouteBinding(),
}),
Positioning: content.Positioning,
Support: content.SupportLine,
Location: content.Location,
Cards: cards,
Artifacts: s.artefactCount(),
Pages: len(s.PublicDocs()),
Areas: len(content.Areas),
}
}
// Doc builds the view for one document at any depth.
func (s *Site) Doc(d *content.Document) templates.DocView {
area, _ := content.AreaByKey(d.Area)
levelLabel := templates.LevelLabel(d.Level)
view := templates.DocView{
Layout: s.Layout(LayoutOpts{
Title: d.Title + " — Sam Rolfe",
Description: content.Truncate(d.Claim, 160),
Area: d.Area,
Crumbs: s.crumbs(d),
Binding: s.RouteBinding(),
}),
Doc: d,
AreaTitle: area.Title,
Role: area.Role,
Body: templates.MarkdownBody(d.HTML),
Metrics: make([]templates.MetricView, 0, len(d.Metrics)),
Tech: d.Tech,
Evidence: s.evidence(d),
Backlinks: s.backlinks(d),
SubPages: s.subPages(d),
LevelLabel: levelLabel,
}
for _, m := range d.Metrics {
view.Metrics = append(view.Metrics, templates.MetricView{Label: m.Label, Value: content.MetricValue(m.Value)})
}
if d.Area == "history" && d.Level <= 2 {
view.Timeline = s.timeline(d.Area)
}
return view
}
func (s *Site) crumbs(d *content.Document) []templates.Crumb {
area, _ := content.AreaByKey(d.Area)
out := []templates.Crumb{
{Label: "Home", Href: "/"},
{Label: area.Title, Href: "/" + d.Area + "/"},
}
if d.Level > 1 {
out = append(out, templates.Crumb{Label: d.Title, Href: d.URL})
}
return out
}
// evidence turns each artefact into a row that loads the artefact into the
// stage panel and is also a working link.
func (s *Site) evidence(d *content.Document) []templates.LinkView {
out := make([]templates.LinkView, 0, len(d.Artefacts))
ids := s.FragmentsByDoc[d.ID]
for _, a := range d.Artefacts {
ra, err := s.Content.Resolve(d, a)
if err != nil {
// Load already rejected dangling artefacts; keep going rather than
// render a broken link.
continue
}
conf := d.Confidence
if ra.Target != nil {
conf = ra.Target.Confidence
}
link := templates.LinkView{
Label: a.Label,
Href: ra.Href,
Kind: ra.Kind,
Confidence: conf,
}
for _, id := range ids {
if f, ok := s.fragByID[id]; ok && f.ArtefactURL == a.URL {
link.HXGet = s.RouteFragmentURL(id)
}
}
out = append(out, link)
}
return out
}
func (s *Site) backlinks(d *content.Document) []templates.LinkView {
out := make([]templates.LinkView, 0, len(d.Backlinks))
for _, b := range d.Backlinks {
out = append(out, templates.LinkView{Label: b.Title, Href: b.URL, Kind: templates.LevelLabel(b.Level)})
}
return out
}
// subPages is the drill-down list under a page. L1 lists the capability pages
// in its area; every other level lists artefacts in the area that this page
// does not already cite, so no page can become unreachable.
func (s *Site) subPages(d *content.Document) []templates.LinkView {
cited := map[string]bool{}
for _, a := range d.Artefacts {
if ra, err := s.Content.Resolve(d, a); err == nil && ra.Target != nil {
cited[ra.Target.ID] = true
}
}
var out []templates.LinkView
for _, other := range s.PublicInArea(d.Area) {
if other.ID == d.ID || cited[other.ID] {
continue
}
if d.Level == 1 && other.Level != 2 {
continue
}
if d.Level > 1 && other.Level < 3 {
continue
}
out = append(out, templates.LinkView{
Label: other.Title,
Href: other.URL,
Kind: other.Confidence,
})
}
return out
}
// timeline orders an area's deeper pages by period, newest first.
func (s *Site) timeline(area string) []templates.TimelineItem {
var items []templates.TimelineItem
for _, d := range s.PublicInArea(area) {
if d.Level < 2 {
continue
}
items = append(items, templates.TimelineItem{
Period: d.Period,
Title: d.Title,
Href: d.URL,
Confidence: d.Confidence,
Claim: d.Claim,
})
}
return items
}
// Evidence is the one-page index of every artefact on the site.
func (s *Site) Evidence() templates.EvidenceView {
var groups []templates.EvidenceGroup
total := 0
for _, a := range content.Areas {
group := templates.EvidenceGroup{AreaTitle: a.Title, Href: "/" + a.Key + "/"}
for _, d := range s.PublicInArea(a.Key) {
for _, l := range s.evidence(d) {
group.Rows = append(group.Rows, templates.EvidenceRow{
Label: d.Title + " · " + l.Label,
Href: l.Href,
HXGet: l.HXGet,
Kind: l.Kind,
Confidence: l.Confidence,
})
total++
}
}
if len(group.Rows) > 0 {
groups = append(groups, group)
}
}
return templates.EvidenceView{
Layout: s.Layout(LayoutOpts{
Title: "Evidence index — Sam Rolfe",
Description: "Every artefact this site claims, on one page.",
Crumbs: []templates.Crumb{{Label: "Home", Href: "/"}, {Label: "Evidence"}},
Binding: s.RouteBinding(),
}),
Groups: groups,
Total: total,
}
}
// StreamKeys is the fragment sequence the streaming panel demonstrates: the six
// area landing fragments, in navigation order. No model is involved.
func (s *Site) StreamKeys() []string {
n := len(content.Areas)
if n > len(s.Fragments) {
n = len(s.Fragments)
}
keys := make([]string, 0, n)
for _, f := range s.Fragments[:n] {
keys = append(keys, f.ID)
}
return keys
}
// Stream is the page that re-points the panel at an event stream. The layout is
// the same as every other page; only the binding differs.
func (s *Site) Stream(endpoint string) templates.StreamView {
keys := s.StreamKeys()
links := make([]templates.LinkView, 0, len(keys))
for _, id := range keys {
if f, ok := s.fragByID[id]; ok {
links = append(links, templates.LinkView{
Label: f.Title,
Href: s.DocURL(f.DocID),
HXGet: s.RouteFragmentURL(id),
Kind: f.Kind,
})
}
}
return templates.StreamView{
Layout: s.Layout(LayoutOpts{
Title: "Streaming panel — Sam Rolfe",
Description: "The presentation panel bound to an event stream instead of pre-rendered files.",
Crumbs: []templates.Crumb{{Label: "Home", Href: "/"}, {Label: "Streaming panel"}},
Binding: s.StreamBinding(endpoint),
}),
Frames: len(links),
Fragment: links,
}
}
// DocURL is the published URL for a document id.
func (s *Site) DocURL(id string) string {
if d, ok := s.Content.ByID(id); ok {
return d.URL
}
return "/"
}
// RouteFragmentURL is where the route driver's fragment file is served from.
func (s *Site) RouteFragmentURL(id string) string {
return stage.RouteDriver{BaseURL: "/stage"}.FragmentURL(id)
}
// Fragment is one pre-rendered stage fragment.
type Fragment struct {
ID string
DocID string
Kind string
Title string
// ArtefactURL is the artefact this fragment was built from, if any. It is
// how an evidence row finds the fragment that belongs to it.
ArtefactURL string
// File is relative to the published root, e.g. stage/doc-edge-overview.html.
File string
// HTML is the rendered fragment markup, written to File at build time.
HTML string
subtitle string
body templ.Component
code *stage.Code
src string
alt string
links []stage.Link
confidence string
docURL string
docTitle string
}
// Fragment returns a pre-rendered fragment by id.
func (s *Site) Fragment(id string) (*Fragment, bool) {
f, ok := s.fragByID[id]
return f, ok
}
func year() int { return time.Now().Year() }

174
internal/site/site_test.go Normal file
View File

@@ -0,0 +1,174 @@
package site
import (
"os"
"path/filepath"
"strings"
"testing"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
)
// newSite loads the repository's own content. If this fails, every other test
// here would be testing nothing.
func newSite(t *testing.T) *Site {
t.Helper()
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
s, err := New(Config{Root: root, OutDir: filepath.Join(t.TempDir(), "dist")})
if err != nil {
t.Fatalf("load site: %v", err)
}
return s
}
// TestSixAreasHaveLandingPages is the DoD line "L1 (6) and L2 (6) pages
// complete": every area has a published level 1 page and at least one level 2
// page, and every area's L1 fragment is the default stream.
func TestSixAreasHaveLandingPages(t *testing.T) {
s := newSite(t)
if len(content.Areas) != 6 {
t.Fatalf("%d areas, want 6", len(content.Areas))
}
if got := len(s.StreamKeys()); got != 6 {
t.Fatalf("stream keys = %d, want the six area landing fragments", got)
}
for i, a := range content.Areas {
l1, ok := s.Content.AreaLanding(a.Key)
if !ok {
t.Errorf("%s: no level 1 page", a.Key)
continue
}
if !l1.Public {
t.Errorf("%s: level 1 page %s is not public", a.Key, l1.ID)
}
if f, ok := s.Fragment(s.StreamKeys()[i]); !ok {
t.Errorf("%s: stream key %s is not a fragment", a.Key, s.StreamKeys()[i])
} else if f.DocID != l1.ID {
t.Errorf("%s: stream fragment %s belongs to %s, want %s", a.Key, f.ID, f.DocID, l1.ID)
}
levels := map[int]int{}
for _, d := range s.PublicInArea(a.Key) {
levels[d.Level]++
}
if levels[2] == 0 {
t.Errorf("%s: no level 2 pages", a.Key)
}
if levels[3] == 0 {
t.Errorf("%s: no level 3 pages", a.Key)
}
}
}
// TestEveryArtefactIsReachableWithinThreeClicks is acceptance test C4 against
// the view models: landing → area → artefact page is three clicks, and every
// artefact has a working link.
func TestEveryArtefactIsReachableWithinThreeClicks(t *testing.T) {
s := newSite(t)
for _, area := range content.Areas {
l1, ok := s.Content.AreaLanding(area.Key)
if !ok {
continue
}
linkedFromL1 := map[string]bool{}
for _, l := range s.Doc(l1).SubPages {
linkedFromL1[l.Href] = true
}
for _, l := range s.Doc(l1).Evidence {
linkedFromL1[l.Href] = true
}
for _, d := range s.PublicInArea(area.Key) {
if d.Level == 1 {
continue
}
if d.Level == 2 {
if !linkedFromL1[d.URL] {
t.Errorf("%s/level 2 page %s is not linked from the area landing page", area.Key, d.URL)
}
continue
}
// Level 3: linked from an L2 page's drill-down or evidence table.
reachable := false
for _, l2 := range s.PublicInArea(area.Key) {
if l2.Level != 2 {
continue
}
view := s.Doc(l2)
for _, l := range view.SubPages {
if l.Href == d.URL {
reachable = true
}
}
for _, l := range view.Evidence {
if l.Href == d.URL {
reachable = true
}
}
}
if !reachable {
t.Errorf("%s/level 3 page %s is not linked from any level 2 page", area.Key, d.URL)
}
}
}
}
// TestEveryArtefactHasAStageFragment keeps the panel honest: if an evidence row
// offers hx-get, the fragment it names must exist.
func TestEveryArtefactHasAStageFragment(t *testing.T) {
s := newSite(t)
for _, d := range s.PublicDocs() {
for _, l := range s.evidence(d) {
if l.HXGet == "" {
t.Errorf("%s: evidence %q has no stage fragment", d.ID, l.Label)
continue
}
id := strings.TrimPrefix(l.HXGet, "/stage/")
if _, ok := s.Fragment(id); !ok {
t.Errorf("%s: evidence %q points at missing fragment %q", d.ID, l.Label, id)
}
}
}
}
// TestBuildWritesEveryPublishedPage runs the real build once and checks the
// files the server depends on.
func TestBuildWritesEveryPublishedPage(t *testing.T) {
s := newSite(t)
if err := s.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
for _, url := range s.PublishedPages() {
file := s.filePathForURL(url)
if !strings.HasPrefix(file, s.Config.OutDir) {
t.Errorf("%s: output escapes the build directory", url)
}
if _, err := os.ReadFile(file); err != nil {
t.Errorf("%s: %v", url, err)
}
}
for _, name := range []string{IndexFile, ManifestFile, BuildInfoFile, "static/site.css", "static/htmx.min.js", "static/chroma.css"} {
if _, err := os.ReadFile(filepath.Join(s.Config.OutDir, filepath.FromSlash(name))); err != nil {
t.Errorf("build output %s: %v", name, err)
}
}
info, err := LoadBuildInfo(s.Config.OutDir)
if err != nil {
t.Fatalf("build info: %v", err)
}
if info.Pages == 0 || info.Fragments == 0 || info.Indexed == 0 {
t.Errorf("build info is empty: %+v", info)
}
// The manifest order is the server's default stream: six document
// fragments first, and they are the six area landings.
if len(s.Manifest.Entries) < 6 {
t.Fatalf("manifest has %d entries", len(s.Manifest.Entries))
}
for _, e := range s.Manifest.Entries[:6] {
if !strings.HasPrefix(e.ID, "doc-") {
t.Errorf("manifest entry %s is not a document fragment; the default stream must be the six areas", e.ID)
}
}
}

317
internal/stage/stage.go Normal file
View File

@@ -0,0 +1,317 @@
// Package stage defines the presentation panel as a content sink rather than a
// page renderer.
//
// The contract (docs/phase-2-static-site.md §2.4):
//
// Stage ← driver(route) // Mode A: HTMX fragment swap, pre-rendered files
// Stage ← driver(SSE) // Mode B: streamed fragments (Phase 5)
//
// Layout code renders a Binding. It never asks which driver produced it, so
// re-pointing the panel at a stream is a one-line change at the call site in
// Mode B's page and no change at all to Layout or Stage.
package stage
import (
"context"
"encoding/json"
"fmt"
"sort"
"github.com/a-h/templ"
)
// Fragment kinds the panel can display. These are the whole vocabulary: adding
// one is a deliberate change to §2.4.
const (
KindText = "text"
KindCode = "code"
KindImage = "image"
KindVideo = "video"
KindDiagram = "diagram"
KindLinks = "links"
)
// PanelID is the element both drivers swap into. The templates render this id
// and the bindings target it, so there is one name for it in the codebase.
const PanelID = "stage-content"
// PanelTarget is the CSS selector for PanelID.
func PanelTarget() string { return "#" + PanelID }
// DocFragmentID is the id of the fragment that carries a document's own body.
// The shape lives here, with the rest of the panel contract, so that a row in an
// evidence table, a pre-rendered file name and a streamed frame all agree on it.
func DocFragmentID(docID string) string { return "doc-" + docID }
// ArtefactFragmentID is the id of the i-th artefact in a document. It is
// derived from the document and the artefact's position, so it stays stable
// across builds and a saved link keeps working when the content is re-rendered.
func ArtefactFragmentID(docID string, i int) string { return fmt.Sprintf("art-%s-%02d", docID, i+1) }
// Mode is how a panel receives content.
type Mode string
const (
// ModeRoute pulls one pre-rendered fragment per interaction. Mode A.
ModeRoute Mode = "route"
// ModeStream receives fragments pushed over SSE. Mode B.
ModeStream Mode = "stream"
)
// Code is a highlighted code exhibit.
type Code struct {
Language string
Label string
HTML templ.Component // chroma-highlighted, or a <pre> fallback
}
// Link is one row of a link-list fragment.
type Link struct {
Label string
Href string
Note string
}
// Fragment is one thing the panel can show. The same struct is rendered to a
// static file for the route driver and pushed over SSE for the stream driver.
type Fragment struct {
ID string
Kind string
Title string
Subtitle string
Body templ.Component // pre-rendered HTML for text fragments
Code *Code
Src string // image, video or diagram source
Alt string
Links []Link
DocURL string
DocTitle string
Confidence string
// Rendered marks Body as a complete fragment: the build already rendered
// the article and everything inside it, so a transport writes these bytes
// as-is instead of wrapping them again. A source that hands over structured
// content leaves it false and the panel renders the fragment normally.
Rendered bool
}
// Binding is everything the Stage component needs to know about its driver.
// Route mode contributes nothing but a marker; stream mode contributes the SSE
// wiring. Both are rendered by the same Stage template.
type Binding struct {
Mode Mode
Endpoint string // stream: the SSE endpoint
Event string // stream: the event name to swap on
Target string // CSS selector the driver swaps into
Swap string // htmx swap style
Label string // human description of the driver, shown in the empty state
Hint string // one line telling the reader what to do
}
// Attributes returns the htmx/SSE attributes for the panel. Only the stream
// driver needs any: in route mode the trigger elements carry hx-get and
// hx-target, because a fragment is chosen by clicking evidence.
func (b Binding) Attributes() templ.Attributes {
attrs := templ.Attributes{"data-stage-driver": string(b.Mode)}
switch b.Mode {
case ModeStream:
attrs["hx-ext"] = "sse"
attrs["sse-connect"] = b.Endpoint
attrs["sse-swap"] = b.Event
attrs["hx-swap"] = b.Swap
attrs["aria-live"] = "polite"
}
return attrs
}
// TargetSelector is the panel a link or a stream should swap into.
func (b Binding) TargetSelector() string {
if b.Target == "" {
return PanelTarget()
}
return b.Target
}
// Driver is the whole contract between the panel and whatever feeds it.
type Driver interface {
Mode() Mode
// Bind returns the binding to render on the Stage panel.
Bind() Binding
}
// RouteDriver feeds the panel from pre-rendered fragment files. Mode A.
type RouteDriver struct {
// BaseURL is where fragment files are served from, e.g. "/stage".
BaseURL string
Label string
Hint string
}
// Mode is part of the Driver contract.
func (RouteDriver) Mode() Mode { return ModeRoute }
// Bind is part of the Driver contract.
func (d RouteDriver) Bind() Binding {
return Binding{
Mode: ModeRoute,
Target: PanelTarget(),
Swap: "innerHTML",
Label: defaultString(d.Label, "Evidence panel · driven by route"),
Hint: defaultString(d.Hint, "Choose a claim from the evidence table; the artefact appears here."),
}
}
// FragmentURL is the pre-rendered URL for a fragment id.
func (d RouteDriver) FragmentURL(id string) string {
base := d.BaseURL
if base == "" {
base = "/stage"
}
return base + "/" + id
}
// SSEDriver feeds the panel from an event stream. Same panel, same fragments,
// different transport. Used by the Phase 5 console; demonstrated in Phase 2 by
// a stream of the pre-built fragments so the plumbing is provable without AI.
type SSEDriver struct {
Endpoint string
Event string
Label string
Hint string
}
// Mode is part of the Driver contract.
func (SSEDriver) Mode() Mode { return ModeStream }
// Bind is part of the Driver contract.
func (d SSEDriver) Bind() Binding {
event := d.Event
if event == "" {
event = "stage"
}
return Binding{
Mode: ModeStream,
Endpoint: d.Endpoint,
Event: event,
Target: PanelTarget(),
Swap: "innerHTML",
Label: defaultString(d.Label, "Evidence panel · driven by stream"),
Hint: defaultString(d.Hint, "Frames arrive as they are produced and replace the panel each time."),
}
}
func defaultString(v, fallback string) string {
if v == "" {
return fallback
}
return v
}
// Source produces the fragments a stream driver pushes. Phase 2 provides
// ManifestSource; Phase 5 provides the retrieval-driven source. Neither
// touches the panel.
type Source interface {
// Stream sends fragments for keys, in order, until done or ctx is done.
// Returning an error ends the stream; the panel keeps the last fragment.
Stream(ctx context.Context, keys []string, send func(Fragment) error) error
}
// Entry is one pre-rendered fragment, as recorded at build time.
type Entry struct {
ID string `json:"id"`
DocID string `json:"doc"`
Title string `json:"title"`
Kind string `json:"kind"`
// File is relative to the published root, e.g. "stage/edge-fleet/1.html".
File string `json:"file"`
}
// Manifest is the build-time index of fragments. The server uses it to stream
// fragments that were rendered once, and the route driver serves the same
// files directly.
type Manifest struct {
Entries []Entry `json:"entries"`
}
// ByID looks an entry up.
func (m *Manifest) ByID(id string) (Entry, bool) {
for _, e := range m.Entries {
if e.ID == id {
return e, true
}
}
return Entry{}, false
}
// ForDoc returns the entries belonging to a document, oldest first.
func (m *Manifest) ForDoc(docID string) []Entry {
var out []Entry
for _, e := range m.Entries {
if e.DocID == docID {
out = append(out, e)
}
}
return out
}
// Marshal encodes the manifest for the build to write.
func (m *Manifest) Marshal() ([]byte, error) { return json.MarshalIndent(m, "", " ") }
// LoadManifest parses a manifest written by the build.
func LoadManifest(b []byte) (*Manifest, error) {
var m Manifest
if err := json.Unmarshal(b, &m); err != nil {
return nil, fmt.Errorf("stage manifest: %w", err)
}
return &m, nil
}
// ManifestSource streams fragments that were pre-rendered at build time. It
// exists to prove the stream path end to end without a model in the loop; the
// Phase 5 source implements the same interface with retrieval behind it.
type ManifestSource struct {
manifest *Manifest
// HTML returns the pre-rendered markup for an entry.
HTML func(Entry) (string, error)
// Frame is an optional hook called as each fragment is sent, for pacing.
Frame func(index int)
}
// NewManifestSource wires a manifest and a reader together.
func NewManifestSource(m *Manifest, read func(Entry) (string, error), frame func(int)) *ManifestSource {
return &ManifestSource{manifest: m, HTML: read, Frame: frame}
}
// Stream is part of the Source contract.
func (s *ManifestSource) Stream(ctx context.Context, keys []string, send func(Fragment) error) error {
entries := make([]Entry, 0, len(keys))
if len(keys) == 0 {
entries = append(entries, s.manifest.Entries...)
}
for _, k := range keys {
if e, ok := s.manifest.ByID(k); ok {
entries = append(entries, e)
continue
}
// A document id expands to that document's fragments.
entries = append(entries, s.manifest.ForDoc(k)...)
}
sort.SliceStable(entries, func(i, j int) bool { return entries[i].ID < entries[j].ID })
for i, e := range entries {
if err := ctx.Err(); err != nil {
return err
}
html, err := s.HTML(e)
if err != nil {
return err
}
if s.Frame != nil {
s.Frame(i)
}
frag := Fragment{ID: e.ID, Kind: e.Kind, Title: e.Title, Body: templ.Raw(html), Rendered: true}
if err := send(frag); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,148 @@
package stage
import (
"context"
"strings"
"testing"
)
// TestRouteDriverBindsWithoutTransport checks §2.4: in route mode the panel
// carries no stream wiring at all, because the trigger elements do the work.
func TestRouteDriverBindsWithoutTransport(t *testing.T) {
d := RouteDriver{BaseURL: "/stage"}
if d.Mode() != ModeRoute {
t.Fatalf("Mode() = %q, want %q", d.Mode(), ModeRoute)
}
b := d.Bind()
if b.Mode != ModeRoute {
t.Errorf("binding mode = %q", b.Mode)
}
if b.TargetSelector() != PanelTarget() {
t.Errorf("target = %q, want %q", b.TargetSelector(), PanelTarget())
}
for _, k := range []string{"hx-ext", "sse-connect", "sse-swap"} {
if _, ok := b.Attributes()[k]; ok {
t.Errorf("route binding has %q; route mode must not open a stream", k)
}
}
if got := d.FragmentURL("doc-x"); got != "/stage/doc-x" {
t.Errorf("FragmentURL = %q", got)
}
}
// TestSSEDriverChangesOnlyTheBinding is the design contract: the same panel,
// the same target, a different transport.
func TestSSEDriverChangesOnlyTheBinding(t *testing.T) {
sse := SSEDriver{Endpoint: "/api/stage/stream"}.Bind()
route := RouteDriver{}.Bind()
if sse.TargetSelector() != route.TargetSelector() {
t.Errorf("stream targets %q, route targets %q; the panel must not move",
sse.TargetSelector(), route.TargetSelector())
}
if sse.Mode == route.Mode {
t.Fatal("stream and route bindings have the same mode")
}
attrs := sse.Attributes()
want := map[string]string{
"hx-ext": "sse",
"sse-connect": "/api/stage/stream",
"sse-swap": "stage",
"hx-swap": "innerHTML",
}
for k, v := range want {
if got := attrs[k]; got != v {
t.Errorf("attribute %s = %v, want %q", k, got, v)
}
}
if attrs["data-stage-driver"] != string(ModeStream) {
t.Errorf("driver marker = %v", attrs["data-stage-driver"])
}
}
// TestManifestSourceServesPreRenderedFragments checks the Phase 2 source: it
// expands a document id, keeps order and marks the payload as already rendered
// so a transport does not wrap the article twice.
func TestManifestSourceServesPreRenderedFragments(t *testing.T) {
m := &Manifest{Entries: []Entry{
{ID: "doc-a", DocID: "a", Kind: KindText, Title: "A", File: "stage/doc-a.html"},
{ID: "art-a-01", DocID: "a", Kind: KindCode, Title: "code", File: "stage/art-a-01.html"},
{ID: "doc-b", DocID: "b", Kind: KindText, Title: "B", File: "stage/doc-b.html"},
}}
read := func(e Entry) (string, error) { return "<article>" + e.ID + "</article>", nil }
src := NewManifestSource(m, read, nil)
var got []Fragment
err := src.Stream(context.Background(), []string{"a"}, func(f Fragment) error {
got = append(got, f)
return nil
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
if len(got) != 2 {
t.Fatalf("streamed %d fragments for doc a, want 2", len(got))
}
for _, f := range got {
if !f.Rendered {
t.Errorf("fragment %s is not marked Rendered; a transport would wrap it again", f.ID)
}
}
if got[0].ID != "art-a-01" || got[1].ID != "doc-a" {
t.Errorf("order = %s,%s; wanted the stable manifest order", got[0].ID, got[1].ID)
}
}
// TestManifestSourceStopsOnCancel checks that a disconnected visitor stops the
// stream instead of leaving a goroutine producing frames (acceptance test N7).
func TestManifestSourceStopsOnCancel(t *testing.T) {
m := &Manifest{Entries: []Entry{
{ID: "doc-a", DocID: "a", Kind: KindText, File: "stage/doc-a.html"},
{ID: "doc-b", DocID: "b", Kind: KindText, File: "stage/doc-b.html"},
}}
read := func(e Entry) (string, error) { return "<article></article>", nil }
ctx, cancel := context.WithCancel(context.Background())
src := NewManifestSource(m, read, func(i int) {
if i == 0 {
cancel()
}
})
sent := 0
err := src.Stream(ctx, nil, func(Fragment) error { sent++; return nil })
if err == nil {
t.Fatal("Stream returned nil after cancellation")
}
if sent >= len(m.Entries) {
t.Errorf("sent all %d frames after cancellation; the stream ignored the context", sent)
}
}
// TestFragmentIDsAreStable keeps the link between an evidence row and its
// fragment stable across builds.
func TestFragmentIDsAreStable(t *testing.T) {
if DocFragmentID("edge-fleet") != "doc-edge-fleet" {
t.Error("document fragment id changed shape")
}
if ArtefactFragmentID("edge-fleet", 0) != "art-edge-fleet-01" {
t.Errorf("artefact fragment id = %q", ArtefactFragmentID("edge-fleet", 0))
}
}
// TestKindsAreTheWholeVocabulary guards §2.4: the panel can show six things.
func TestKindsAreTheWholeVocabulary(t *testing.T) {
kinds := []string{KindText, KindCode, KindImage, KindVideo, KindDiagram, KindLinks}
seen := map[string]bool{}
for _, k := range kinds {
if strings.TrimSpace(k) == "" {
t.Fatal("empty fragment kind")
}
if seen[k] {
t.Errorf("duplicate fragment kind %q", k)
}
seen[k] = true
}
if len(kinds) != 6 {
t.Errorf("%d fragment kinds, want 6", len(kinds))
}
}

12
internal/web/assets.go Normal file
View File

@@ -0,0 +1,12 @@
// Package web embeds the browser-side assets: htmx, the SSE extension and the
// stylesheet. They are compiled into the binary, so the built site has no
// dependency on a bundler, a CDN or the working directory.
package web
import "embed"
// Static holds everything served from /static/. The build publishes it
// verbatim, and adds a chroma.css generated from the Markdown renderer's style.
//
//go:embed static
var Static embed.FS

1
internal/web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1360
internal/web/static/site.css Normal file

File diff suppressed because it is too large Load Diff

290
internal/web/static/sse.js Normal file
View File

@@ -0,0 +1,290 @@
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('sse', {
/**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef
// set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource
}
},
getSelectors: function() {
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @returns void
*/
onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
// Try to remove remove an EventSource when elements are removed
var source = internalData.sseEventSource
if (source) {
api.triggerEvent(parent, 'htmx:sseClose', {
source,
type: 'nodeReplaced',
})
internalData.sseEventSource.close()
}
return
// Try to create EventSources when elements are processed
case 'htmx:afterProcessNode':
ensureEventSourceOnElement(parent)
}
}
})
/// ////////////////////////////////////////////
// HELPER FUNCTIONS
/// ////////////////////////////////////////////
/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, { withCredentials: true })
}
/**
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Add message handlers for every `sse-swap` attribute
if (api.getAttributeValue(elt, 'sse-swap')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
var sseEventNames = sseSwapAttr.split(',')
for (var i = 0; i < sseEventNames.length; i++) {
const sseEventName = sseEventNames[i].trim()
const listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(elt)) {
source.removeEventListener(sseEventName, listener)
return
}
// swap the response into the DOM and trigger a notification
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
return
}
swap(elt, event.data)
api.triggerEvent(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(sseEventName, listener)
}
}
// Add message handlers for every `hx-trigger="sse:*"` attribute
if (api.getAttributeValue(elt, 'hx-trigger')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var triggerSpecs = api.getTriggerSpecs(elt)
triggerSpecs.forEach(function(ts) {
if (ts.trigger.slice(0, 4) !== 'sse:') {
return
}
var listener = function (event) {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(elt)) {
source.removeEventListener(ts.trigger.slice(4), listener)
}
// Trigger events to be handled by the rest of htmx
htmx.trigger(elt, ts.trigger, event)
htmx.trigger(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(ts.trigger.slice(4), listener)
})
}
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null
}
// handle extension source creation attribute
if (api.getAttributeValue(elt, 'sse-connect')) {
var sseURL = api.getAttributeValue(elt, 'sse-connect')
if (sseURL == null) {
return
}
ensureEventSource(elt, sseURL, retryCount)
}
registerSSE(elt)
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url)
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
var timeout = retryCount * 500
window.setTimeout(function() {
ensureEventSourceOnElement(elt, retryCount)
}, timeout)
}
}
source.onopen = function(evt) {
api.triggerEvent(elt, 'htmx:sseOpen', { source })
if (retryCount && retryCount > 0) {
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
for (let i = 0; i < childrenToFix.length; i++) {
registerSSE(childrenToFix[i])
}
// We want to increase the reconnection delay for consecutive failed attempts only
retryCount = 0
}
}
api.getInternalData(elt).sseEventSource = source
var closeAttribute = api.getAttributeValue(elt, "sse-close");
if (closeAttribute) {
// close eventsource when this message is received
source.addEventListener(closeAttribute, function() {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'message',
})
source.close()
});
}
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource
if (source != undefined) {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'nodeMissing',
})
source.close()
// source = null
return true
}
}
return false
}
/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt)
})
var swapSpec = api.getSwapSpecification(elt)
var target = api.getTarget(elt)
api.swap(target, content, swapSpec)
}
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null
}
})()

View File

@@ -0,0 +1,343 @@
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 a GET form: it works with JavaScript disabled and is upgraded by
// HTMX when scripts are available.
templ SearchForm(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">
<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"/>
<button type="submit">Search</button>
</form>
}
// 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">
<a class="pillar-go" href={ templ.SafeURL(c.Href) }>
if c.Level == 1 {
<span>Start here</span>
} else {
<span>Read the capability page</span>
}
</a>
<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 with the honesty notes a recruiter needs.
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-note">
Status labels are literal: <span class="conf conf-running">running</span> and
<span class="conf conf-delivered">delivered</span> are claims; <span class="conf conf-in-progress">in-progress</span> and
<span class="conf conf-abandoned">abandoned</span> are stated as such.
</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"
}

View File

@@ -0,0 +1,254 @@
package templates
import "strconv"
// HomePage is the landing page: the 90-second read.
templ HomePage(v HomeView) {
@Layout(v.Layout) {
<section class="hero">
<p class="hero-kicker">Melbourne · twenty-five years · AI · platform · edge</p>
<h1 class="hero-title">{ v.Positioning }</h1>
<p class="hero-support">{ v.Support }</p>
<p class="hero-facts">
<span>{ strconv.Itoa(v.Areas) } areas</span>
<span>{ strconv.Itoa(v.Pages) } pages</span>
<span>{ strconv.Itoa(v.Artifacts) } linked artefacts</span>
<span>no page needs a model to render</span>
</p>
@SearchForm("", "hero-search")
</section>
<section class="pillars" aria-label="Six areas">
for _, c := range v.Cards {
@PillarCard(c)
}
</section>
<section class="honesty" aria-labelledby="how-to-read">
<h2 id="how-to-read">How to read this site</h2>
<ul>
<li><strong>Level 1</strong> is one screen per area: the claim, the numbers, the way in.</li>
<li><strong>Level 2</strong> is what is actually run, with an evidence table and the trade-offs.</li>
<li><strong>Level 3</strong> is the artefact: code, diagram, screenshot, log.</li>
<li>Every claim carries a <strong>status</strong>, and finished, running, in-progress and abandoned are all shown as they are.</li>
<li>Every artefact is at most three clicks from here, and the <a href="/evidence/">evidence index</a> lists all of them on one page.</li>
</ul>
</section>
}
}
// DocPage renders an L1, L2 or L3 document.
templ DocPage(v DocView) {
@Layout(v.Layout) {
<article class="doc">
<header class="doc-head">
<p class="doc-kicker">{ v.AreaTitle } · { v.LevelLabel }</p>
<h1 class="doc-title">{ v.Doc.Title }</h1>
<p class="doc-claim">{ v.Doc.Claim }</p>
<div class="doc-chips">
<span class={ ConfidenceClass(v.Doc.Confidence) }>{ v.Doc.Confidence }</span>
if v.Doc.Period != "" {
<span class="chip chip-plain">{ v.Doc.Period }</span>
}
for _, m := range v.Metrics {
<span class="chip">
<strong>{ m.Value }</strong>
<span>{ m.Label }</span>
</span>
}
</div>
if len(v.Tech) > 0 {
<ul class="tech" aria-label="Technology">
for _, t := range v.Tech {
<li>{ t }</li>
}
</ul>
}
</header>
<div class="prose">
@v.Body
</div>
if len(v.Evidence) > 0 {
<section class="doc-evidence" aria-labelledby="evidence-heading">
<h2 id="evidence-heading">Evidence for this page</h2>
<p class="section-note">Select a row to load the artefact into the presentation panel. Each row is also a link, so this works without JavaScript.</p>
@EvidenceTable(evidenceRows(v.Evidence))
</section>
}
if len(v.Timeline) > 0 {
<section class="doc-timeline" aria-labelledby="timeline-heading">
<h2 id="timeline-heading">In time order</h2>
@Timeline(v.Timeline)
</section>
}
if len(v.SubPages) > 0 {
<section class="doc-sub" aria-labelledby="sub-heading">
<h2 id="sub-heading">{ subHeading(v.Doc.Level) }</h2>
<ul class="drill-list">
for _, s := range v.SubPages {
<li>
<a href={ templ.SafeURL(s.Href) }>{ s.Label }</a>
<span class="drill-kind">{ s.Kind }</span>
</li>
}
</ul>
</section>
}
if len(v.Backlinks) > 0 {
<section class="doc-back" aria-labelledby="back-heading">
<h2 id="back-heading">Referenced from</h2>
<ul class="drill-list">
for _, b := range v.Backlinks {
<li><a href={ templ.SafeURL(b.Href) }>{ b.Label }</a></li>
}
</ul>
</section>
}
<footer class="doc-foot">
<p>Status <span class={ ConfidenceClass(v.Doc.Confidence) }>{ v.Doc.Confidence }</span> — { ConfidenceNote(v.Doc.Confidence) }</p>
<p>Last verified { v.Doc.LastVerified } · id <code>{ v.Doc.ID }</code> · { v.Doc.Path }</p>
</footer>
</article>
}
}
// EvidencePage is one page listing every artefact, grouped by area. It is the
// shortest path from the landing page to any proof.
templ EvidencePage(v EvidenceView) {
@Layout(v.Layout) {
<article class="doc">
<header class="doc-head">
<p class="doc-kicker">Index</p>
<h1>Evidence index</h1>
<p class="doc-claim">Every artefact this site claims, on one page. { strconv.Itoa(v.Total) } links, each one a file in the repository.</p>
</header>
for _, g := range v.Groups {
<section class="evidence-group">
<h2><a href={ templ.SafeURL(g.Href) }>{ g.AreaTitle }</a></h2>
@EvidenceTable(g.Rows)
</section>
}
</article>
}
}
// SearchPage is the keyword search. The results block is swapped by HTMX on the
// same page; with JavaScript off the form simply reloads the page.
templ SearchPage(v SearchView) {
@Layout(v.Layout) {
<article class="doc">
<header class="doc-head">
<p class="doc-kicker">Keyword search</p>
<h1>Search { strconv.Itoa(v.Indexed) } pages</h1>
<p class="doc-claim">Full-text search over page titles and body text. Served from a local index — no model, no network call.</p>
</header>
@SearchForm(v.Query, "page-search")
@SearchResults(v)
</article>
}
}
// SearchResults is swapped into #search-results by HTMX.
templ SearchResults(v SearchView) {
<div id="search-results" aria-live="polite">
if v.Query == "" {
<p class="section-note">Try “zigbee”, “signage”, “routing”, “backups” or “guardrails”.</p>
} else if v.Failed {
<p class="search-empty">That query could not be run. Try fewer or simpler words.</p>
} else if len(v.Results) == 0 {
<p class="search-empty">No page matches <q>{ v.Query }</q>. The <a href="/evidence/">evidence index</a> lists everything by area.</p>
} else {
<p class="search-count">{ strconv.Itoa(len(v.Results)) } results for <q>{ v.Query }</q></p>
<ol class="results">
for _, r := range v.Results {
<li class="result">
<h2><a href={ templ.SafeURL(r.URL) }>{ r.Title }</a></h2>
<p class="result-meta">
<span class="kind">{ r.Area }</span>
<span class="kind">L{ strconv.Itoa(r.Level) }</span>
<span class={ ConfidenceClass(r.Confidence) }>{ r.Confidence }</span>
</p>
<p class="result-snippet">@SafeSnippet(r.Snippet)</p>
</li>
}
</ol>
}
</div>
}
// 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) {
@Layout(v.Layout) {
<article class="doc">
<header class="doc-head">
<p class="doc-kicker">Design note · not a product feature</p>
<h1>The panel takes content from a driver</h1>
<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
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
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>
</div>
if len(v.Fragment) > 0 {
<section class="doc-sub">
<h2>Fragments in this stream</h2>
<ul class="drill-list">
for _, f := range v.Fragment {
<li>
<a href={ templ.SafeURL(f.Href) } hx-get={ f.HXGet } hx-target="#stage-content" hx-swap="innerHTML">{ f.Label }</a>
<span class="drill-kind">{ f.Kind }</span>
</li>
}
</ul>
</section>
}
</article>
}
}
// NotFoundPage is the 404.
templ NotFoundPage(v NotFoundView) {
@Layout(v.Layout) {
<article class="doc">
<header class="doc-head">
<p class="doc-kicker">404</p>
<h1>That page is not here</h1>
<p class="doc-claim">The link may be from an older build. The six areas and the evidence index are the reliable ways in.</p>
</header>
<ul class="drill-list">
<li><a href="/">Landing page</a></li>
<li><a href="/evidence/">Evidence index</a></li>
<li><a href="/search">Search</a></li>
</ul>
</article>
}
}
func evidenceRows(links []LinkView) []EvidenceRow {
rows := make([]EvidenceRow, 0, len(links))
for _, l := range links {
rows = append(rows, EvidenceRow{Label: l.Label, Href: l.Href, HXGet: l.HXGet, Kind: l.Kind, Confidence: l.Confidence})
}
return rows
}
func subHeading(level int) string {
if level == 1 {
return "Drill-downs in this area"
}
return "Artefacts behind this page"
}

View File

@@ -0,0 +1,229 @@
// Package templates holds every templ component and the view models they read.
// Layout never branches on the stage driver: it renders the binding it is given.
//
// The generated *_templ.go files are not committed; `make generate` writes them
// from the repository root before every build. There is deliberately no
// //go:generate directive: templ records the .templ path in its error messages,
// so generating from a package directory and generating from the root produce
// different files. One entry point keeps the output byte-identical.
package templates
import (
stdhtml "html"
"strings"
"github.com/a-h/templ"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
)
// NavItem is one left-nav entry.
type NavItem struct {
Title string
Href string
Gutter string
Current bool
}
// Crumb is one breadcrumb.
type Crumb struct {
Label string
Href string
}
// LayoutView is everything the chrome needs.
type LayoutView struct {
Title string
Description string
Area string
Nav []NavItem
Crumbs []Crumb
Stage stage.Binding
SearchQuery string
Year int
SkipAI bool // true on Mode A pages, which never need a model
}
// StageView wraps the binding so the Stage component has one argument.
type StageView struct {
Binding stage.Binding
// Fragment, when set, is rendered into the panel at build time.
Fragment *stage.Fragment
}
// MetricView is one approved chip.
type MetricView struct {
Label string
Value string
}
// LinkView is a drill-down link. HXGet makes it a stage trigger; Href keeps it
// a working link when JavaScript is off.
type LinkView struct {
Label string
Href string
HXGet string
Kind string
Note string
Confidence string
}
// EvidenceRow is one row of an EvidenceTable.
type EvidenceRow struct {
Label string
Href string
HXGet string
Kind string
Confidence string
}
// EvidenceGroup groups rows by area on the evidence index.
type EvidenceGroup struct {
AreaTitle string
Href string
Rows []EvidenceRow
}
// TimelineItem is one row of a timeline.
type TimelineItem struct {
Period string
Title string
Href string
Confidence string
Claim string
}
// PillarCardView is one of the six landing cards.
type PillarCardView struct {
Area content.Area
Title string
Claim string
Href string
Metrics []MetricView
Tech []string
Confidence string
Level int
Pages int
}
// Layout of each page kind.
type (
// HomeView is the landing page.
HomeView struct {
Layout LayoutView
Positioning string
Support string
Location string
Cards []PillarCardView
Artifacts int
Pages int
Areas int
}
// DocView is an L1, L2 or L3 content page.
DocView struct {
Layout LayoutView
Doc *content.Document
AreaTitle string
Role string
Body templ.Component
Metrics []MetricView
Tech []string
Evidence []LinkView
Backlinks []LinkView
SubPages []LinkView
Timeline []TimelineItem
LevelLabel string
}
// EvidenceView is the index of every artefact.
EvidenceView struct {
Layout LayoutView
Groups []EvidenceGroup
Total int
}
// SearchView is the keyword search page.
SearchView struct {
Layout LayoutView
Query string
Results []search.Result
Total int
Indexed int
Failed bool
}
// StreamView is the SSE-driven stage demonstration page.
StreamView struct {
Layout LayoutView
Frames int
Fragment []LinkView
}
// NotFoundView is the 404 page.
NotFoundView struct {
Layout LayoutView
}
)
// MarkdownBody wraps pre-rendered Markdown HTML.
func MarkdownBody(html string) templ.Component { return templ.Raw(html) }
// SafeSnippet escapes a search snippet but keeps the <mark> tags the index
// inserted. Content is in-repo and trusted; escaping is defence in depth.
func SafeSnippet(s string) templ.Component {
s = stdhtml.EscapeString(s)
s = strings.ReplaceAll(s, "&lt;mark&gt;", "<mark>")
s = strings.ReplaceAll(s, "&lt;/mark&gt;", "</mark>")
return templ.Raw(s)
}
// ConfidenceClass maps a confidence value onto a CSS class. The value itself is
// always displayed — never softened.
func ConfidenceClass(c string) string {
switch c {
case content.ConfidenceDelivered:
return "conf conf-delivered"
case content.ConfidenceRunning:
return "conf conf-running"
case content.ConfidenceInProgress:
return "conf conf-in-progress"
case content.ConfidenceAbandoned:
return "conf conf-abandoned"
case content.ConfidenceHistoric:
return "conf conf-historic"
}
return "conf"
}
// ConfidenceNote is the sentence shown under a status badge.
func ConfidenceNote(c string) string {
switch c {
case content.ConfidenceDelivered:
return "Delivered."
case content.ConfidenceRunning:
return "Running today."
case content.ConfidenceInProgress:
return "In progress — not finished."
case content.ConfidenceAbandoned:
return "Abandoned. Kept in the record deliberately."
case content.ConfidenceHistoric:
return "Historic — worked on, not maintained today."
}
return ""
}
// LevelLabel names a depth level for the page kicker.
func LevelLabel(level int) string {
switch level {
case 1:
return "Level 1 · capability in one screen"
case 2:
return "Level 2 · what is actually run"
case 3:
return "Level 3 · artefact"
}
return ""
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>

After

Width:  |  Height:  |  Size: 65 B

View File

@@ -0,0 +1,21 @@
---
id: audio
area: security
level: 2
title: "Distributed Audio"
claim: "Eight synchronised endpoints with music ducking for voice."
confidence: running
period: "2023-"
tech: [Snapcast, Mopidy, Spotify]
metrics: [{label: "endpoints", value: 8}]
artefacts:
- type: doc
label: "Architecture note"
url: "content/edge/l3/audio-architecture.md"
- {type: image, label: "Rack diagram", url: "content/assets/audio-rack.svg"}
public: true
last_verified: 2026-10-20
tags: [audio, snapcast]
---
# Distributed Audio

View File

@@ -0,0 +1,17 @@
---
id: audio-architecture
area: edge
level: 3
title: "Audio architecture note"
claim: "How the eight endpoints stay in sync."
confidence: running
period: "2023-"
tech: []
metrics: []
artefacts: []
public: true
last_verified: 2026-10-20
tags: [audio]
---
# Audio architecture note

Some files were not shown because too many files have changed in this diff Show More