10 Commits

Author SHA1 Message Date
Sam Rolfe
36d8e514d4 content: six real code exhibits, rendered by the panel (AUD-152) 2026-10-28 16:00:06 +11:00
2ceb0c147c platforms: add the three platform guides the /platforms/ page renders
The /platforms/ page (408d6cd) reads platforms/*.yml and fails the build when
they are absent. Those three files were introduced on feat/phase-4-ingestion by
the ingestion commit 1e46ec0 and nothing else on the site needed them, so the
cherry-pick of 408d6cd left the tree unbuildable.

They are data, not ingestion code: each file is config plus public copy that the
site shows verbatim so a visitor can see exactly what the assistant reads. No
corpus/, no harvester, no Prefect flow and no Qdrant code is included.

Taken verbatim from 1e46ec0.

go test ./...: pass.
2026-10-28 15:56:50 +11:00
cb45c462be diagram: keep the runtime note inside its own panel
The note under the loopback box ran under the edge panel at 11.5 px. Shortened
so it ends inside the runtime panel at every render size. Re-measured after the
change: performance 100, accessibility 100.

(cherry picked from commit 4fb759cf22c3609542600009536613a04e461746)
2026-10-28 15:56:13 +11:00
6ac0e1ff47 visuals: print styles for the wall and the diagrams, and Phase 7 notes
- Diagrams carry a print style block, so they print as ink on white rather than
  as dark panels; the client wall and the chips go white with a hairline.
- docs/phase-7-notes.md records what batch 1 shipped, how to add a mark or an
  image, and the Lighthouse measurements taken after the batch (100/100/100/100,
  CLS 0, mobile and desktop).

(cherry picked from commit ae9282e5f62b8c7ee149ebf96f8d3f00067a49e2)
2026-10-28 15:56:12 +11:00
a76882de67 visuals: technology marks, area icons, client wall, two diagrams
Phase 7 (AUD-149), cheapest first.

- internal/brand: vendored Simple Icons path data (CC0) for 50 marks, looked up
  by the technology label the content already uses. Inline SVG, 16 px,
  currentColor, no request and no font. tools/gen_brand_icons.sh regenerates it.
- Six hand-drawn area icons replace the two-letter badges in the nav and on the
  landing cards; the evidence index gets a seventh.
- TechChips renders a mark where one exists and plain text where one does not,
  so a missing icon reads as a decision rather than a broken image.
- Client wall: 45 names as one typeface, one size, one grid of equal tiles, and
  the reason for no logo collage written on the page. No mark invented.
- Two diagrams, authored in-repo: homelab topology (platform-network.md) and
  the RAG pipeline (ai-rag-corpus.md), each referenced from the page it explains.
- Content images now carry intrinsic width/height, loading="lazy" and
  decoding="async" at render time; a site test fails the build if an image
  ships without them.
- Topbar is two rows at desktop: the area names plus marks no longer overlap the
  search form (Lighthouse target-size was failing at 1440 px).
- Favicon, so the browser stops requesting a missing /favicon.ico.

Satisfies: logos on every area page, area icons in nav and cards, two diagrams
referenced from their pages, images sized and lazy, Lighthouse >= 95 held.

(cherry picked from commit a473e4738a3751418c2097df2bcf0568fb6d0208)
2026-10-28 15:56:11 +11:00
Paperclip Builder
59580b6e45 site: publish the platform guides at /platforms/ and link them from an About page
(cherry picked from commit 408d6cdfd76d13945db1d9727b58feca23d1e48b)
2026-10-28 15:55:36 +11:00
d793ab0871 search: make the navigation form work from every page (AUD-151)
The nav form carried hx-get="/search" hx-target="#search-results", but
#search-results exists only on /search. On every other page htmx found no
target and silently discarded the response: a search box that looks like it
works and does nothing.

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

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

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

(cherry picked from commit a56c795768e1e9b10204aae04c3612d7a96775a7)
2026-10-28 15:55:34 +11:00
Documenter
286aa12bb0 site: cut the template's own labels out of the pages
Point 7 of the page-wording review. The pages no longer show their data
structure as headings, and they no longer explain their own conventions.

- Level 1 is one screen: claim, status badge, three figures, the technology
  row, and one sentence carrying one link. Measured in headless Chrome at
  1440x900: 1404-1845 px tall before, exactly 900 px (no scroll) after, on
  all six area pages.
- DocPage: no kicker line, no visible level label, no "Evidence for this
  page" heading or instruction note, no "Drill-downs" list, no
  "Status <word> - sentence" footer. The artefact table starts at level 2,
  under the heading "Artefacts"; the history timeline is level 2 only.
- Landing page: "Start here" link text gone, "How to read this site" panel
  gone, the footer's status-label explainer gone.
- Content, presentation only, no claim softened: the leading H1 that
  duplicated the page title removed from all 41 documents; level 1 bodies
  reduced to the constraint as ordinary prose plus the way in;
  "**Status: ...**" prefixes removed from the artefact stubs; "## Drill-downs"
  and "## Period and confidence" removed from the level 2 pages, with the
  cross-area links moved into the prose.
- The six level 1 claims are now sentences with a subject, and use commas
  instead of middots. Same facts, no new ones.

Not in this commit: the 35 level 2 and 3 claims still use the compact CV
style. That is a voice decision for Sam, not a wording tidy-up.

make validate: 41 documents, 0 violations. go test ./...: pass.

(cherry picked from commit 03009f80fc7815ad7719412e125df9297a6e653f)
2026-10-28 15:55:33 +11:00
Sam Rolfe
f39f560c7f deploy: publish on the LAN address so Caddy on .35 can reach it
127.0.0.1:8096 is unreachable from .35, which is where Caddy runs. Every other
service on .13 is published this way (8095, 8092, 3909, 4200) and proxied by the
same Caddy. Port is LAN-only; Caddy remains the sole public listener.
2026-10-26 22:40:24 +11:00
Sam Rolfe
df864cc29e deploy: health gate and shape assertion follow the LAN publish address
Once the port is published on 192.168.20.13 the host no longer listens on
127.0.0.1:8096, so wait_healthy(), do_status() and the rollback path would all
get connection-refused and every deploy would roll back. They now probe
$PUBLISH_IP (RESUME_PUBLISH_IP, default 192.168.20.13). assert_shape() asserts
the published host IP is that address instead of loopback; the mem_limit ==
memswap_limit checks are unchanged. check_port stays address-agnostic on
`sport = :8096`.

Records the amendment in reference/stack.md (ports table + the bind rule) and
docs/phase-3-deploy.md (§3.2 bullet, flow diagram, DoD line), citing Sam's
decision of 2026-10-26, interaction 54aeb1a2. AGENTS.md is Sam's to change and
is untouched.
2026-10-26 22:40:22 +11:00
76 changed files with 2169 additions and 472 deletions

View File

@@ -49,7 +49,7 @@ The honest framing is in the evidence notes and is repeated here: *full-time par
## 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.
The same three areas at a larger scale: [AI and agentic systems](../ai/ai-systems.md), the [platform engineering underneath them](../platform/platform-estate.md), and the [constrained devices at the edge](../edge/edge-fleet.md). Melbourne based and flexible about location and format — remote, hybrid, office, or travel.
## Contact policy
@@ -60,16 +60,3 @@ This site publishes no address, no phone number and no family details. Contact i
- **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

@@ -3,7 +3,7 @@ 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."
claim: "Sam has shipped software for twenty-five years — self-taught, through a non-IT working life and five years overseas — and now builds AI systems, the platforms under them and the devices at the edge."
confidence: running
period: "1999–"
tech: [NixOS, Neovim, Python, JavaScript, React, Go, HTMX]
@@ -13,13 +13,4 @@ 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.
The other five areas prove what can be built; this one explains where the judgement came from. The narrative, including 2020 to 2026, is on the [narrative page](about-narrative.md).

View File

@@ -14,9 +14,7 @@ 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.
Formal presentation lands in Phase 7. No certificates, transcripts or student identifiers are published.
## The record

View File

@@ -14,9 +14,7 @@ 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.

View File

@@ -14,9 +14,7 @@ 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 are final; the presentation lands in Phase 7.
## The roles

View File

@@ -0,0 +1,49 @@
---
id: about-sources
area: about
level: 3
title: "What the assistant reads"
claim: "Four sources, one pool, and a published rule for every one of them."
confidence: running
period: "2026–"
tech: [Gitea, Obsidian, Graphify, Qdrant, SQLite]
metrics: []
artefacts: []
public: true
last_verified: 2026-10-26
tags: [ai, sources, corpus, retrieval]
---
# What the assistant reads
The assistant answers from a pool built out of four sources, not from a single
folder of copy.
| Source | Tier | What it contributes |
|---|---|---|
| This site's content | 1 | The curated claims: what Sam says is true about the work |
| The Gitea estate | 2 | README files, file trees, selected source files, commit messages and language statistics |
| The notes vault | 2 | Project and area notes, in Sam's own words |
| The knowledge graph | 2 | Nodes and edges over the vault, each joined back to the note it came from |
Tier 1 wins when the two disagree. A harvested README can be out of date; the
curated claim is the one that is maintained.
Every chunk records the platform it came from, the URL or repository path it can
be opened at, the area it feeds, its tier, its honest confidence and when it last
changed. A chunk without an origin is refused, not defaulted.
The rules are not hidden in code. Each platform has a guide that says what it is,
where it lives, what is included, what is excluded and whether it is enabled.
Those guides are shown at [the sources page](/platforms/) and are the same files
the ingestion pipeline reads.
## What is excluded
`node_modules`, virtualenvs, git internals, trash, editor state, the SilverBullet
auth file, credential-shaped files, binaries and media. Media is recorded as an
image reference and never turned into text. Personal and family notes are not
read.
The finished pool is scanned for credential-shaped content before a run can
succeed, so the exclusion list is checked rather than trusted.

View File

@@ -3,7 +3,7 @@ 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."
claim: "Sam 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]
@@ -13,13 +13,4 @@ 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.
The pipelines run against real obligations — school portals, calendars, email, household audio — and they are operated rather than demonstrated. What actually runs, and what it cost to get there, is on the [capability page](ai-systems.md).

View File

@@ -14,8 +14,6 @@ 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
@@ -55,16 +53,4 @@ tags: [ai, agents, routing, retrieval, voice, guardrails]
- **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.
The period starts at the homelab year in the evidence notes: the AI estate and the homelab have the same origin.

View File

@@ -14,9 +14,7 @@ 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.
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

View File

@@ -14,9 +14,7 @@ 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.
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

View File

@@ -14,9 +14,7 @@ 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.
A redacted session handoff and a before-and-after context measurement land in Phase 7.
## What the artefact is

View File

@@ -14,9 +14,7 @@ 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.
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

View File

@@ -14,9 +14,7 @@ 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.
A redacted run log of one pipeline lands in Phase 7, alongside the pipeline inventory.
## What the artefact is

View File

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

View File

@@ -14,9 +14,7 @@ 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.
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

View File

@@ -0,0 +1,36 @@
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

@@ -0,0 +1,53 @@
// Excerpt from js/utils/text_effects.js (2,156 lines) in the WebsiteBuilder
// source. Two regions are reproduced verbatim: the timeline loop that derives
// each letter's delay from an easing function (lines 1111-1147), and the
// hand-written easing functions themselves (lines 1340-1349).
$(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);
// --- easing functions, lines 1340-1349 ---
//t,b,c,d
//currenttime,start,change,duration
//t and do can be time or frames
function positionsCubic(t, b, c, d){
t /= d;
return c*t*t*t + b;
}
function positionSine(t, b, c, d) {
return -c * Math.cos(t/d * (Math.PI/2)) + c + b;
}

View File

@@ -0,0 +1,54 @@
// Excerpt from php/setMediaEncoder.php (3,402 lines) in the WebsiteBuilder
// source: the create_gif encode operation, lines 614-665, reproduced verbatim.
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);
// $cmd_2='mogrify -delay '.$_POST['compress_gif_delay'].' -alpha remove '.$matte.' -depth '.$_POST['compress_gif_depth'].' '.$deconstruct.' '.$dither.' -colors '.$_POST['compress_gif_colors'].' -fuzz '.$_POST['compress_gif_fuzz'].'% '.$loop.' '.$optimize.' '.$path.' 2>&1';
$_POST['loop_gif']="true";
$_POST['compress_gif_matte']="false";
$_POST['compress_gif_deconstruct']="false";
$_POST['compress_gif_dither']="true";
$_POST['compress_gif_optimize']="true";
$loop=($_POST['loop_gif']=="false")?"-loop 1":"";
$matte=($_POST['compress_gif_matte']=="true")?"-matte":"";
$deconstruct=($_POST['compress_gif_deconstruct']=="true")?"-deconstruct":"";
$dither=($_POST['compress_gif_dither']=="true")?"-dither none ":"";
$optimize=($_POST['compress_gif_optimize']=="true")?"-layers optimize ":"";
// $optimize=($_POST['compress_gif_optimize']=="true")?" -coalesce -layers RemoveDups ":"";
$_POST['compress_gif_depth']=16;
$_POST['compress_gif_delay']=0;
$_POST['compress_gif_fuzz']="4";
$_POST['compress_gif_colors']="16";
$cmd_2='mogrify -delay '.$_POST['compress_gif_delay'].' -alpha remove '.$matte.' -depth '.$_POST['compress_gif_depth'].' '.$deconstruct.' '.$dither.' -colors '.$_POST['compress_gif_colors'].' -fuzz '.$_POST['compress_gif_fuzz'].'% '.$loop.' '.$optimize.' '.$output_gif.' 2>&1';
$result=shell_exec($cmd_2);
$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 );
return $returnFiles;
}

View File

@@ -0,0 +1,38 @@
// 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

@@ -0,0 +1,31 @@
// Two files from firmware/src/hw, reproduced verbatim: WiFiMgr.hpp (the
// interface) and WiFiMgr.cpp (the implementation). The boundary is marked.
// 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

@@ -0,0 +1,33 @@
{ 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

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

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

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

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -8,14 +8,12 @@ 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"}]
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"}, {type: repo, label: "Embedded C++ — the Wi-Fi manager interface and implementation", url: "content/assets/code/05-embedded-cpp-interface.hpp"}]
public: true
last_verified: 2026-10-26
last_verified: 2026-10-28
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
@@ -32,6 +30,10 @@ tags: [edge, iot, esp32, lora, zigbee, audio]
**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.
## Why this exhibit is good code
The header declares the interface and includes only `Arduino.h`, so a caller compiles without pulling the ESP32 Wi-Fi 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. The two files are shown together because the point is the split, not either file alone.
## Evidence
| Claim | Evidence | Artefact |
@@ -50,16 +52,4 @@ tags: [edge, iot, esp32, lora, zigbee, audio]
- **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.
Individual nodes carry their own label, and the ones being rebuilt or postponed are labelled that way on their pages.

View File

@@ -3,7 +3,7 @@ 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."
claim: "Sam builds and maintains a twenty-node edge fleet — constrained devices on solar power and deep sleep, with custom radio and sensors — that reports into a single automation layer."
confidence: running
period: "2020–"
tech: [ESP32, LoRa, Zigbee, MQTT, Home Assistant, Snapcast, Raspberry Pi]
@@ -13,13 +13,4 @@ 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.
The devices are outdoors on solar and asleep most of their lives, and they have to keep working when nobody is looking; what failed is written down. The architecture, the radio and the failures are on the [fleet page](edge-fleet.md).

View File

@@ -14,9 +14,7 @@ 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.
One annotated diagram of a representative node and the radio path lands in Phase 7.
## What the artefact is

View File

@@ -14,9 +14,7 @@ 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.
Two items are being rebuilt, one is postponed. The page is not finished because the work is not finished.
## What the artefact is

View File

@@ -14,9 +14,7 @@ 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.
The device was built and is being rebuilt. The page states this rather than describing it as finished.
## What the artefact is

View File

@@ -14,9 +14,7 @@ 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.
The bill of materials and the wake-on-radio change land in Phase 7.
## What the artefact is

View File

@@ -14,9 +14,7 @@ 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.
A sensor and actuator table lands in Phase 7.
## What the artefact is

View File

@@ -14,9 +14,7 @@ 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.
The topology diagram and a short recording of ducking land in Phase 7.
## What the artefact is

View File

@@ -14,9 +14,7 @@ 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.
The router build and a zone map land in Phase 7.
## What the artefact is

View File

@@ -14,8 +14,6 @@ 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
@@ -52,12 +50,4 @@ The point of the exhibit set is that the tooling changed repeatedly and the deli
- **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.
This work is finished, and it is presented in the past tense throughout.

View File

@@ -3,7 +3,7 @@ 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."
claim: "Sam 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]
@@ -13,13 +13,4 @@ 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.
The languages changed six times across the 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. The era-by-era record is on the [engagement record](history-engagements.md).

View File

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

View File

@@ -8,28 +8,34 @@ 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: []
artefacts: [{type: repo, label: "ActionScript 3 — the Siren class", url: "content/assets/code/01-actionscript-class.as"}]
public: true
last_verified: 2026-10-26
last_verified: 2026-10-28
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.
**Status: published.** This page shows the ActionScript class and indexes the other five, each of which is exhibited on the page where it belongs.
## The six
| # | Era | Source | What the excerpt shows |
| # | Era | Exhibit | Where it is shown |
|---|---|---|---|
| 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 |
| 1 | Object-oriented ActionScript | `01-actionscript-class.as` — class, imports, encapsulation | this page |
| 2 | Hand-written JavaScript engine | `02-js-easing-core.js` — easing functions and the timeline loop | [the site builder](../../products/l3/products-websitebuilder.md) |
| 3 | PHP media pipeline | `03-php-encode-op.php` — one GIF encode operation and its FFmpeg commands | [the site builder](../../products/l3/products-websitebuilder.md) |
| 4 | TypeScript | `04-typescript-service-layer.ts` — a typed tRPC boundary | [the estate](../../platform/platform-estate.md) |
| 5 | Embedded C++ | `05-embedded-cpp-interface.hpp` — interface split from implementation | [the edge fleet](../../edge/edge-fleet.md) |
| 6 | Declarative infrastructure | `06-nix-derivation.nix` — pinned version, verified hash, exit condition | [the Nix package](../../platform/l3/platform-tuios-package.md) |
Total is roughly 225 lines across twenty years, which is the point: each excerpt is chosen to show judgement rather than volume.
Total is 243 lines across twenty years, which is the point: each excerpt is chosen to show judgement rather than volume.
## Why this exhibit is good code
Every field is private and explicitly typed, and the 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.
## Limits stated on the page
The excerpt is verbatim, including a latent typo (`this.high` rather than `this.height`). It is left as found: the excerpt is evidence, not a portfolio piece.
## What it proves
@@ -37,4 +43,4 @@ That the stack changed six times and the discipline did not. Exhibit 6 is the le
## 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.
Published. The six files are in this repository under `content/assets/code/`; the archive path is withheld. The other five are linked above from the pages that carry their claims.

View File

@@ -14,9 +14,7 @@ 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.
Redacted job definitions and the restore runbook land in Phase 7. Passphrase locations and remote paths are removed before publication.
## What the artefact is

View File

@@ -14,9 +14,7 @@ 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.
A digest of the inventory — grouped by purpose, with addresses removed — lands in Phase 7.
## What the artefact is

View File

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

View File

@@ -14,9 +14,7 @@ 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.
A redacted excerpt of the configuration tree lands in Phase 7. No hostnames, addresses or keys are published.
## What the artefact is

View File

@@ -14,9 +14,7 @@ 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.
The CEC command set and a layout diagram land in Phase 7.
## What the artefact is

View File

@@ -8,15 +8,13 @@ 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: []
artefacts: [{type: repo, label: "Nix derivation — tuios 0.10.1 override with a removal condition", url: "content/assets/code/06-nix-derivation.nix"}]
public: true
last_verified: 2026-10-26
last_verified: 2026-10-28
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.
**Status: published.** The whole file is shown verbatim.
## What the artefact is
@@ -26,6 +24,10 @@ A package definition for the agent operations console: `fetchurl` against a rele
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.
## Why this exhibit is good code
The override records why it exists and the exact condition under which it should be deleted, which is the part of a local patch that usually goes missing. Pinning a released binary by `fetchurl` with a verified hash keeps the result reproducible without waiting for nixpkgs.
## When it is delivered
Phase 7. The file is in the infrastructure repository; source fact: content spine section 4.2.
Published. The file is in this repository at `content/assets/code/06-nix-derivation.nix`; the infrastructure repository remains the source. Source fact: content spine section 4.2.

View File

@@ -8,14 +8,12 @@ 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"}]
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"}, {type: repo, label: "TypeScript — a typed tRPC service boundary", url: "content/assets/code/04-typescript-service-layer.ts"}]
public: true
last_verified: 2026-10-26
last_verified: 2026-10-28
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
@@ -34,6 +32,10 @@ tags: [platform, devops, nixos, docker, backups, networking]
**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.
## Why this exhibit is good code
The Prisma schema defines the stored types, and the tRPC router validates input with Zod at the boundary, 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.
## Evidence
| Claim | Evidence | Artefact |
@@ -51,16 +53,3 @@ tags: [platform, devops, nixos, docker, backups, networking]
- **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

@@ -3,7 +3,7 @@ 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."
claim: "Sam runs a multi-machine NixOS and Proxmox estate sized to the hardware rather than the budget: fifty-plus containers, declarative configuration, encrypted backups with a written restore runbook, and public ingress."
confidence: running
period: "2020–"
tech: [NixOS, Proxmox, Docker, Caddy, Tailscale, Borg, Kopia, AWS S3]
@@ -13,13 +13,4 @@ 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.
The runtime host is memory-bound rather than CPU-bound, so every service carries a memory cap and is bound to loopback. What runs, where, and what was rejected is on the [estate page](platform-estate.md).

View File

@@ -14,9 +14,7 @@ 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.
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

View File

@@ -8,14 +8,12 @@ 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: []
artefacts: [{type: repo, label: "Text-effects engine — easing functions and the timeline loop", url: "content/assets/code/02-js-easing-core.js"}, {type: repo, label: "Media encoder — one GIF encode operation and its FFmpeg commands", url: "content/assets/code/03-php-encode-op.php"}]
public: true
last_verified: 2026-10-26
last_verified: 2026-10-28
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
@@ -26,10 +24,16 @@ The templated builder: pick a template, build the site, publish it — with edit
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.
## Why these exhibits are good code
The animation engine evaluates a named easing function 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.
The media pipeline checks the real MIME type before it builds a command, so a wrong input returns a message instead of a half-written file. The GIF encode takes two passes — a palette from the clip, then frames mapped onto it — which is the difference between a GIF that holds its colour and one that does not.
## 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.
The PHP is 2014-era procedural code: global state, interpolated shell commands, and long functions. The exhibit is here for the operational judgement, not as a model of structure. 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.
Phase 7. Both code exhibits are published at `content/assets/code/`; a screen recording of the editors is still to come. Source: résumé (2014–2020 freelance) and evidence notes section 3.

View File

@@ -14,9 +14,7 @@ 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.
Deployed is true, commercial is not — that is the exact wording the site uses, deliberately.
## What the artefact is

View File

@@ -14,8 +14,6 @@ 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
@@ -55,13 +53,6 @@ The third product is different in kind: Where-Woof is a QR and RFID tag system f
- **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
Signage infrastructure is also platform work: [screens as a managed fleet](../platform/l3/platform-signage.md).
- [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.
Each product carries its 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

@@ -3,7 +3,7 @@ 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."
claim: "Sam 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]
@@ -13,13 +13,4 @@ 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.
Building, selling, deploying and still supporting software for other businesses is a different discipline from writing it, and the caveats on these three products are stated rather than hidden. The commercial story is on the [products page](products-business.md).

View File

@@ -30,11 +30,13 @@ services:
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.
# Published on this host's LAN address, not loopback. Caddy lives on .35 and
# proxies to 192.168.20.13:8096, exactly as it does for offsite (:8095),
# photo-filter (:8092), garage-ui (:3909) and prefect (:4200). The port is
# reachable inside the house only; Caddy on .35 remains the sole public
# listener, which is the intent of AGENTS.md section 4.
ports:
- "127.0.0.1:8096:8096"
- "192.168.20.13:8096:8096"
read_only: true
security_opt:
- no-new-privileges:true

View File

@@ -44,6 +44,15 @@ PROJECT=ai-resume-site # compose project; distinct from the older
PORT=8096
HEALTH_PATH=/healthz
# The host address the port is published on. Caddy runs on .35 and proxies to
# this address, so a loopback publish is unreachable from the edge (Sam's
# decision, 2026-10-26, interaction 54aeb1a2). Never 0.0.0.0: the port must not
# be reachable off the LAN. The health gate below follows this address — once the
# port is published on the LAN address the host no longer listens on
# 127.0.0.1:8096, so a loopback probe would refuse and every deploy would roll
# back.
PUBLISH_IP=${RESUME_PUBLISH_IP:-192.168.20.13}
ROOT=${RESUME_DEPLOY_ROOT:-/home/sam/deployment/resume}
RELEASES=$ROOT/releases
CURRENT=$ROOT/current
@@ -231,7 +240,7 @@ start() {
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 body=$(curl -fsS --max-time 3 "http://$PUBLISH_IP:$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
@@ -240,14 +249,15 @@ wait_healthy() {
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
curl -sS --max-time 3 "http://$PUBLISH_IP:$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.
# published on $PUBLISH_IP only (the LAN address Caddy proxies to — never
# 0.0.0.0 and no longer loopback).
assert_shape() {
local cid mem swap ip declared
declared=$(awk '/^[[:space:]]*mem_limit:/ {gsub(/[^0-9]/, "", $2); print $2}' "$COMPOSE")
@@ -258,8 +268,8 @@ assert_shape() {
[ "$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"
[ "$ip" = "$PUBLISH_IP" ] || die "published on '${ip}', expected $PUBLISH_IP only"
log "shape ok: mem_limit == memswap_limit == ${declared} MiB, published on $PUBLISH_IP:$PORT"
}
deploy_release() {
@@ -348,7 +358,7 @@ prune() {
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)
body=$(curl -fsS --max-time 3 "http://$PUBLISH_IP:$PORT$HEALTH_PATH" 2>/dev/null || true)
printf 'root %s
' "$ROOT"
printf 'current %s

View File

@@ -30,7 +30,7 @@ deploy.sh
├─ copy to releases/<timestamp>/
├─ flip `current` symlink (atomic)
├─ docker compose up -d
├─ curl http://127.0.0.1:8096/healthz → must be ready
├─ curl http://192.168.20.13:8096/healthz → must be ready
└─ on failure: flip back to `previous`, exit non-zero
```
@@ -40,7 +40,12 @@ deploy.sh
### 3.2 Container
- `mem_limit` = `memswap_limit` = `120m`.
- `ports: ["127.0.0.1:8096:8096"]` — never `0.0.0.0`.
- `ports: ["192.168.20.13:8096:8096"]` — **never** `0.0.0.0`.
Caddy runs on `.35`, so the port has to be reachable from the LAN; a
`127.0.0.1` publish answers only on `.13` and the public URL returns 502.
LAN-only publish, Caddy on `.35` still the sole public listener. Decision:
Sam, 2026-10-26, interaction `54aeb1a2`. The health gate and `assert_shape()`
in `deploy.sh` follow the same address (`RESUME_PUBLISH_IP`).
- `logging` capped (`max-size: 10m`, `max-file: 3`).
- `restart: unless-stopped`.
@@ -75,7 +80,7 @@ Repo stays private through this phase. Sam decides on public visibility after th
- [ ] `deploy.sh` and `deploy.sh --rollback` both work; a deliberate bad build proves rollback.
- [ ] `https://resume.lab.audasmedia.com.au` serves the site.
- [ ] Container bound to `127.0.0.1` only; confirmed with `ss -ltn`.
- [ ] Container published on `192.168.20.13:8096` only, reachable from `.35`; confirmed with `ss -ltn`.
- [ ] `memswap_limit == mem_limit`; confirmed with `docker inspect`.
- [ ] Security headers present; CSP does not break the page.
- [ ] A1 still passes over the public URL with the AI layer stopped.

71
docs/phase-7-notes.md Normal file
View File

@@ -0,0 +1,71 @@
# Phase 7 — notes
Running record for the visual layer (AUD-149). Each batch states what changed,
what it measures, and what it still needs.
---
## Batch 1 — marks, area icons, client wall, two diagrams
### What is in the tree
| Piece | Where | Note |
|---|---|---|
| Technology marks | `internal/brand/` | 50 Simple Icons paths (CC0), inlined as 16 px `currentColor` SVG. No request, no font, no bundler. |
| Regenerator | `tools/gen_brand_icons.sh` | Development-time only. The generated Go file is committed, so `make build` never needs the network. |
| Area icons | `internal/web/templates/icons.templ` | Six marks drawn for this site, plus one for the evidence index. 24×24, 1.6 px stroke, `aria-hidden`. |
| Client wall | `content/history/l3/history-client-wall.md` + `.client-wall` in `site.css` | 45 names, one typeface, one size, one grid of equal tiles. No client mark is used, because none has been approved. |
| Diagrams | `content/assets/diagrams/` | Homelab topology and RAG pipeline. Both referenced from the page they explain. |
| Image sizing | `internal/content/markdown.go` | Every content image gets intrinsic `width`/`height`, `loading="lazy"`, `decoding="async"`. |
| Guard | `internal/site/site_test.go` | `TestImagesReserveTheirSpace` fails if an image ships without size or alt text. |
| Topbar | `site.css` | Two rows at desktop. One row overlapped the search form once the marks were added; Lighthouse `target-size` caught it. |
| Favicon | `internal/web/static/favicon.svg` | Stops the browser asking for a missing `/favicon.ico` (it was the only console error). |
### Adding a technology mark
1. Add the label → slug pair to `slugs` in `internal/brand/brand.go`.
2. Add the slug to `slugs()` in `tools/gen_brand_icons.sh` and run it.
3. `make dist`, then check the chip on the page.
A technology with no published mark renders as text alone, on purpose: a chip
with a missing image would read as a broken page.
### Adding an image
- File goes under `content/assets/` (published at `/assets/...`).
- SVG needs explicit `width` and `height` on the root element; PNG and JPEG are
measured with `image.DecodeConfig`. Both give the page its reserved space.
- Alt text describes the thing, not the file name.
- The diagrams carry their own print style, so they print as ink on white.
### Measurements (2026-10-28)
Lighthouse 12.8.2, headless Chrome, against the local server on `127.0.0.1`.
| Page | Mode | Performance | Accessibility | Best practices | SEO | CLS |
|---|---|---|---|---|---|---|
| Landing | mobile | 100 | 100 | 100 | 100 | 0 |
| Landing | desktop | 100 | 100 | 100 | 100 | 0 |
| Platform network (diagram) | mobile | 100 | 100 | 100 | 100 | 0 |
| AI retrieval (diagram) | desktop | 100 | 100 | 100 | 100 | 0 |
| Client wall | mobile | 100 | 100 | 100 | 100 | — |
| Edge audio artefact | mobile | 100 | 100 | 100 | 100 | — |
Total page weight: landing 153 KiB, diagram page 105 KiB. Diagram files are 5.7
KiB and 6.6 KiB; the stylesheet is 24 KiB; htmx is 51 KiB and is the largest
single asset.
Two defects were found by measurement rather than by eye and fixed in this
batch: the desktop topbar overlap, and the missing favicon.
### Still open — needs Sam
| DoD line | State | What is needed |
|---|---|---|
| At least one real photograph in use | Not started | Which photographs may be used (Immich on `.35` is the likely source). Anything used is checked for people, address and screens in shot. |
| Two screenshots in use | Not started | Approval to capture Home Assistant, Gitea, TUIOS with agent panes, or Graphify's `graph.html`. Screens must contain nothing personal. |
| One recording, 30–60 s | Not started | Either Sam records it, or Sam approves a recording of the agent console. |
| Client wall marks | Deliberately absent | Official marks only, with Sam's approval. Nothing invented. |
Nothing above is blocking the site: it ships, measures 100 across the board, and
every page works with the AI services stopped.

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

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

160
internal/brand/icons_gen.go Normal file

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -413,9 +413,10 @@ func (s *Server) fragmentNotFound(w http.ResponseWriter, msg string) {
_, _ = 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.
// handleSearch runs a keyword search. With HTMX it returns just the contents of
// the results block, which the search page's own form swaps into #search-results;
// without JavaScript the same handler returns the whole page, so the form works
// in both cases.
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
failed := false
@@ -451,7 +452,9 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
Render(context.Context, io.Writer) error
} = templates.SearchPage(view)
if r.Header.Get("HX-Request") == "true" {
c = templates.SearchResults(view)
// The element that holds #search-results stays where it is, so the
// fragment carries the contents and not the wrapper.
c = templates.SearchResultsBody(view)
}
if err := c.Render(r.Context(), &buf); err != nil {
s.log.Error("render search", "error", err)

View File

@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
@@ -274,8 +275,14 @@ func TestSearchWorksWithAndWithoutHTMX(t *testing.T) {
if strings.Contains(frag, "<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)
// The fragment is swapped into #search-results with innerHTML, so it
// must not carry the wrapper again: a second element with the same id
// inside the first is invalid and makes the live region announce twice.
if strings.Contains(frag, `id="search-results"`) {
t.Errorf("htmx search %q re-declares #search-results inside itself", tc.q)
}
if !strings.Contains(frag, "results for") {
t.Errorf("htmx search %q returned no results", tc.q)
}
}
}
@@ -369,3 +376,190 @@ func TestFirstByteBudget(t *testing.T) {
t.Errorf("first byte in %s, budget is 100ms", elapsed)
}
}
// navSearchForm isolates the navigation search form's opening tag and markup.
// The layout has one such form on every page; there is nothing to search for if
// it is missing.
func navSearchForm(t *testing.T, path, body string) string {
t.Helper()
marker := strings.Index(body, `class="search nav-search"`)
if marker < 0 {
t.Fatalf("%s: no navigation search form", path)
}
start := strings.LastIndex(body[:marker], "<form")
end := strings.Index(body[marker:], "</form>")
if start < 0 || end < 0 {
t.Fatalf("%s: could not isolate the navigation search form", path)
}
return body[start : marker+end]
}
// TestNavigationSearchNavigatesFromEveryPage is the AUD-151 regression: the nav
// form used to carry hx-target="#search-results", which only exists on /search,
// so htmx silently discarded the response everywhere else. The nav form must be
// a plain GET, and no page may target a fragment it does not carry.
func TestNavigationSearchNavigatesFromEveryPage(t *testing.T) {
ts := newServer(t)
pages := []string{
"/",
"/ai/", "/platform/", "/edge/", "/products/", "/history/", "/about/",
"/evidence/", "/edge/edge-fleet/", "/404.html",
}
for _, path := range pages {
code, _, body := get(t, ts, path, nil)
if code != http.StatusOK {
t.Errorf("%s: status %d", path, code)
continue
}
form := navSearchForm(t, path, body)
if strings.Contains(form, "hx-") {
t.Errorf("%s: navigation search form carries an htmx attribute:\n%s", path, form)
}
if !strings.Contains(form, `action="/search"`) {
t.Errorf("%s: navigation search form does not post to /search:\n%s", path, form)
}
if !strings.Contains(form, `method="get"`) {
t.Errorf("%s: navigation search form is not a GET:\n%s", path, form)
}
if strings.Contains(body, `hx-target="#search-results"`) {
t.Errorf("%s: page targets #search-results, which only the search page carries", path)
}
}
}
// TestSearchFromAnyPageLandsOnResults follows the nav form the way a browser with
// JavaScript disabled would: GET the form action with q set, expect a page.
func TestSearchFromAnyPageLandsOnResults(t *testing.T) {
ts := newServer(t)
for _, path := range []string{"/", "/edge/", "/platform/"} {
code, _, body := get(t, ts, path, nil)
if code != http.StatusOK {
t.Errorf("%s: status %d", path, code)
continue
}
form := navSearchForm(t, path, body)
if !strings.Contains(form, `action="/search"`) {
t.Errorf("%s: nav form has no /search action to follow", path)
continue
}
// No HX-Request header: this is the no-JavaScript path.
code, _, landed := get(t, ts, "/search?q=zigbee", nil)
if code != http.StatusOK {
t.Errorf("search from %s: status %d", path, code)
continue
}
if !strings.Contains(strings.ToLower(landed), "<!doctype html>") {
t.Errorf("search from %s did not land on a page", path)
}
if !strings.Contains(landed, "results for") || !strings.Contains(landed, "/edge/") {
t.Errorf("search from %s landed on a page with no results", path)
}
}
}
// TestNoHTMXTargetIsMissingFromItsPage walks the whole built site: every
// hx-target selector must name an element in the same document. This is the
// general form of the AUD-151 bug, so the next one fails a test instead of
// failing silently in a browser.
func TestNoHTMXTargetIsMissingFromItsPage(t *testing.T) {
target := regexp.MustCompile(`hx-target="([^"]+)"`)
files := 0
err := filepath.WalkDir(distDir, func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(p, ".html") {
return nil
}
raw, err := os.ReadFile(p)
if err != nil {
return err
}
files++
body := string(raw)
for _, m := range target.FindAllStringSubmatch(body, -1) {
selector := m[1]
if !strings.HasPrefix(selector, "#") {
continue // closest/find targets are resolved from the trigger
}
id := strings.TrimPrefix(selector, "#")
if !strings.Contains(body, `id="`+id+`"`) {
t.Errorf("%s: hx-target=%q has no matching element", p, selector)
}
}
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", distDir, err)
}
if files == 0 {
t.Fatal("no HTML files scanned; the build fixture is empty")
}
}
// TestSearchEmptyQueryShowsAPrompt covers "an empty query shows a prompt, not an
// empty list".
func TestSearchEmptyQueryShowsAPrompt(t *testing.T) {
ts := newServer(t)
code, _, body := get(t, ts, "/search", nil)
if code != http.StatusOK {
t.Fatalf("status = %d", code)
}
if !strings.Contains(body, "Type one or two words above") {
t.Error("empty search shows no prompt")
}
if strings.Contains(body, `class="results"`) {
t.Error("empty search renders a results list")
}
}
// TestSearchWithNoMatchesSuggestsTheAreas covers "a query with no matches shows a
// clear nothing-found message and suggests browsing the areas".
func TestSearchWithNoMatchesSuggestsTheAreas(t *testing.T) {
ts := newServer(t)
code, _, body := get(t, ts, "/search?q=appaloosafoundation", nil)
if code != http.StatusOK {
t.Fatalf("status = %d", code)
}
if !strings.Contains(body, "Nothing found for") {
t.Error("no nothing-found message")
}
for _, href := range []string{`href="/ai/"`, `href="/platform/"`, `href="/edge/"`, `href="/evidence/"`} {
if !strings.Contains(body, href) {
t.Errorf("nothing-found message does not offer %s", href)
}
}
}
// TestSearchCountIsAnnounced checks the count is inside the polite live region
// and marked as a status, so a screen reader reads "4 results for …" rather than
// an undifferentiated block of titles.
func TestSearchCountIsAnnounced(t *testing.T) {
ts := newServer(t)
_, _, body := get(t, ts, "/search?q=zigbee", nil)
if !strings.Contains(body, `id="search-results" aria-live="polite"`) {
t.Error("results container is not a live region")
}
if !regexp.MustCompile(`<p class="search-count" role="status">\d+ results for`).MatchString(body) {
t.Error("result count is not announced as a status inside the live region")
}
}
// TestHTMXSearchStillReturnsTheFragment keeps the search page's own upgrade: with
// the header set, the handler returns the block and not a whole page.
func TestHTMXSearchStillReturnsTheFragment(t *testing.T) {
ts := newServer(t)
code, _, frag := get(t, ts, "/search?q=zigbee", map[string]string{"HX-Request": "true"})
if code != http.StatusOK {
t.Fatalf("status = %d", code)
}
if strings.Contains(frag, "<html") {
t.Error("htmx search returned a whole page")
}
if strings.Contains(frag, `id="search-results"`) {
t.Error("htmx search re-declares #search-results inside itself")
}
if !strings.Contains(frag, `role="status"`) || !strings.Contains(frag, "results for") {
t.Error("htmx search did not return the results block")
}
}

View File

@@ -100,6 +100,13 @@ func (s *Site) Build() error {
if err := writePage("/404.html", templates.NotFoundPage(s.NotFoundPage())); err != nil {
return err
}
// The platform guides are configuration and public copy at the same time.
// Rendering them here keeps the page and the pipeline's include/exclude rules
// from drifting: both read platforms/*.yml.
if _, err := RenderPlatforms(s.Config.Root, s.Config.OutDir); err != nil {
return fmt.Errorf("platform guides: %w", err)
}
pages++
for _, f := range s.Fragments {
if err := writeFile(filepath.Join(s.Config.OutDir, filepath.FromSlash(f.File)), []byte(f.HTML)); err != nil {

View File

@@ -120,7 +120,7 @@ func (s *Site) docFragment(d *content.Document) *Fragment {
DocID: d.ID,
Kind: stage.KindText,
Title: d.Title,
subtitle: area.Title + " · " + templates.LevelLabel(d.Level),
subtitle: area.Title,
body: templ.Raw(d.HTML),
confidence: d.Confidence,
docURL: d.URL,

128
internal/site/platforms.go Normal file
View File

@@ -0,0 +1,128 @@
package site
import (
"encoding/json"
"fmt"
"html/template"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// PlatformGuide is one `platforms/<name>.yml` file: configuration and public
// copy at the same time. The pipeline reads include/exclude from here; the site
// shows what, where it lives, and what is read.
type PlatformGuide struct {
Name string `yaml:"name" json:"name"`
Enabled bool `yaml:"enabled" json:"enabled"`
What string `yaml:"what" json:"what"`
Fits []string `yaml:"fits" json:"fits"`
Base string `yaml:"base" json:"base"`
Read []string `yaml:"read" json:"read"`
Public bool `yaml:"public" json:"public"`
Include struct {
Repos any `yaml:"repos" json:"repos"`
Paths []string `yaml:"paths" json:"paths"`
} `yaml:"include" json:"include"`
Exclude struct {
Repos []string `yaml:"repos" json:"repos"`
Paths []string `yaml:"paths" json:"paths"`
} `yaml:"exclude" json:"exclude"`
}
// LoadPlatforms reads every guide. A file that does not parse is an error: the
// site must not silently show a different list from the one the pipeline reads.
func LoadPlatforms(root string) ([]PlatformGuide, error) {
dir := filepath.Join(root, "platforms")
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var out []PlatformGuide
for _, entry := range entries {
if entry.IsDir() || !(strings.HasSuffix(entry.Name(), ".yml") || strings.HasSuffix(entry.Name(), ".yaml")) {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
return nil, err
}
var guide PlatformGuide
if err := yaml.Unmarshal(raw, &guide); err != nil {
return nil, fmt.Errorf("%s: %w", entry.Name(), err)
}
if guide.Name == "" || guide.What == "" {
return nil, fmt.Errorf("%s: name and what are required", entry.Name())
}
out = append(out, guide)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
// RenderPlatforms writes /platforms/index.html and /platforms/index.json.
//
// The page is generated outside the templ layout because templ needs a Node-free
// generator on PATH and the build host has no network requirement. It uses the
// same stylesheet, the same navigation and the same wording as the rest of the
// site; the only thing it does not have is the presentation panel, which has no
// meaning on a reference page.
func RenderPlatforms(root, outDir string) (int, error) {
guides, err := LoadPlatforms(root)
if err != nil {
return 0, err
}
target := filepath.Join(outDir, "platforms")
if err := os.MkdirAll(target, 0o755); err != nil {
return 0, err
}
var body strings.Builder
body.WriteString(`<p class="lede">The assistant reads four sources. Three are harvested from the estate; the fourth is the curated text this site is built from. Everything below is the same configuration the pipeline runs.</p>`)
for _, guide := range guides {
state := "enabled"
if !guide.Enabled {
state = "disabled"
}
body.WriteString(`<section class="platform">`)
fmt.Fprintf(&body, `<h2>%s <span class="conf">%s</span></h2>`, template.HTMLEscapeString(guide.Name), state)
fmt.Fprintf(&body, `<p>%s</p>`, template.HTMLEscapeString(strings.TrimSpace(guide.What)))
fmt.Fprintf(&body, `<dl><dt>Where it lives</dt><dd>%s</dd>`, template.HTMLEscapeString(guide.Base))
fmt.Fprintf(&body, `<dt>Feeds</dt><dd>%s</dd>`, template.HTMLEscapeString(strings.Join(guide.Fits, ", ")))
fmt.Fprintf(&body, `<dt>Reads</dt><dd>%s</dd>`, template.HTMLEscapeString(strings.Join(guide.Read, ", ")))
if len(guide.Include.Paths) > 0 {
fmt.Fprintf(&body, `<dt>Includes</dt><dd>%s</dd>`, template.HTMLEscapeString(strings.Join(guide.Include.Paths, ", ")))
}
if len(guide.Exclude.Paths) > 0 {
fmt.Fprintf(&body, `<dt>Excludes</dt><dd>%s</dd>`, template.HTMLEscapeString(strings.Join(guide.Exclude.Paths, ", ")))
}
body.WriteString(`</dl></section>`)
}
page, err := template.New("platforms").Parse(platformsPage)
if err != nil {
return 0, err
}
file, err := os.Create(filepath.Join(target, "index.html"))
if err != nil {
return 0, err
}
defer file.Close()
if err := page.Execute(file, map[string]any{"Body": template.HTML(body.String())}); err != nil {
return 0, err
}
raw, err := json.MarshalIndent(map[string]any{"version": 1, "platforms": guides}, "", " ")
if err != nil {
return 0, err
}
if err := os.WriteFile(filepath.Join(target, "index.json"), append(raw, '\n'), 0o644); err != nil {
return 0, err
}
return len(guides), nil
}
const platformsPage = `<!doctype html><html lang="en-AU"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sources and platform guides — Sam Rolfe</title><meta name="description" content="Every source the assistant reads, with the include and exclude rules, shown as configuration and as public copy."><meta name="color-scheme" content="dark"><link rel="stylesheet" href="/static/site.css"></head><body><a class="skip-link" href="#main">Skip to content</a><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><nav class="nav nav-rail" aria-label="Sections"><a href="/ai/" class="nav-link"><span class="nav-gutter" aria-hidden="true">AI</span> <span class="nav-title">AI &amp; Agentic Systems</span></a><a href="/platform/" class="nav-link"><span class="nav-gutter" aria-hidden="true">PL</span> <span class="nav-title">Platform &amp; Infrastructure</span></a><a href="/edge/" class="nav-link"><span class="nav-gutter" aria-hidden="true">ED</span> <span class="nav-title">Edge &amp; Devices</span></a><a href="/products/" class="nav-link"><span class="nav-gutter" aria-hidden="true">PR</span> <span class="nav-title">Products</span></a><a href="/history/" class="nav-link"><span class="nav-gutter" aria-hidden="true">HI</span> <span class="nav-title">History</span></a><a href="/about/" class="nav-link"><span class="nav-gutter" aria-hidden="true">AB</span> <span class="nav-title">About</span></a><a href="/evidence/" class="nav-link"><span class="nav-gutter" aria-hidden="true">EV</span> <span class="nav-title">Evidence index</span></a></nav></header><div class="shell"><main id="main" class="main"><nav class="crumbs" aria-label="Breadcrumb"><ol><li><a href="/">Home</a></li><li><span aria-current="page">Sources</span></li></ol></nav><article class="doc"><header class="doc-head"><p class="doc-kicker">AI overlay</p><h1>What the assistant reads</h1><p class="doc-claim">Four sources, one pool. Content is tier 1: a curated claim outranks harvested text when the two disagree.</p></header>{{.Body}}<p class="doc-foot"><a href="/evidence/">Every artefact</a> · <a href="/search">Search the site</a></p></article></main></div><footer class="footer"><p class="footer-line">Twenty-five years shipping software · Melbourne · remote · hybrid · office · travel</p><p class="footer-links"><a href="/evidence/">Evidence index</a> <a href="/search">Search</a></p></footer></body></html>`

View File

@@ -0,0 +1,42 @@
package site
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRenderPlatformsShowsTheGuides(t *testing.T) {
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
out := t.TempDir()
count, err := RenderPlatforms(root, out)
if err != nil {
t.Fatalf("RenderPlatforms: %v", err)
}
if count != 3 {
t.Fatalf("got %d guides, want 3", count)
}
raw, err := os.ReadFile(filepath.Join(out, "platforms", "index.html"))
if err != nil {
t.Fatalf("read page: %v", err)
}
page := strings.ToLower(string(raw))
for _, want := range []string{
"what the assistant reads",
"self-hosted git",
"personal knowledge base",
"knowledge graph over the notes",
"node_modules",
} {
if !strings.Contains(page, want) {
t.Errorf("page does not contain %q", want)
}
}
if _, err := os.Stat(filepath.Join(out, "platforms", "index.json")); err != nil {
t.Errorf("index.json missing: %v", err)
}
}

View File

@@ -59,13 +59,23 @@ func (Shell) Nav(area string) []templates.NavItem {
items = append(items, templates.NavItem{
Title: a.Title,
Href: "/" + a.Key + "/",
Gutter: a.Gutter,
Area: a.Key,
Current: a.Key == area,
})
}
return items
}
// AreaLinks is the six areas as plain links. The search page offers them when a
// query matches nothing, so an empty result is still a way into the site.
func (Shell) AreaLinks() []templates.LinkView {
out := make([]templates.LinkView, 0, len(content.Areas))
for _, a := range content.Areas {
out = append(out, templates.LinkView{Label: a.Title, Href: "/" + a.Key + "/"})
}
return out
}
// RouteBinding is the Mode A driver: one pre-rendered fragment per click.
func (Shell) RouteBinding() stage.Binding {
return stage.RouteDriver{BaseURL: "/stage"}.Bind()
@@ -114,6 +124,7 @@ func (sh Shell) SearchView(indexed int, q string, results []search.Result, faile
Results: results,
Indexed: indexed,
Failed: failed,
Areas: sh.AreaLinks(),
}
}
@@ -257,7 +268,6 @@ func (s *Site) Home() templates.HomeView {
// 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{
@@ -267,16 +277,14 @@ func (s *Site) Doc(d *content.Document) templates.DocView {
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,
Doc: d,
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),
}
for _, m := range d.Metrics {
view.Metrics = append(view.Metrics, templates.MetricView{Label: m.Label, Value: content.MetricValue(m.Value)})
@@ -334,7 +342,7 @@ func (s *Site) evidence(d *content.Document) []templates.LinkView {
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)})
out = append(out, templates.LinkView{Label: b.Title, Href: b.URL})
}
return out
}

View File

@@ -7,6 +7,7 @@ import (
"testing"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search"
)
// newSite loads the repository's own content. If this fails, every other test
@@ -172,3 +173,125 @@ func TestBuildWritesEveryPublishedPage(t *testing.T) {
}
}
}
// copyTree copies a directory tree. The freshness test needs its own content
// tree so it can change a page without touching the repository.
func copyTree(src, dst string) error {
return filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
raw, err := os.ReadFile(p)
if err != nil {
return err
}
return os.WriteFile(target, raw, 0o644)
})
}
// TestSearchIndexFollowsContent is the AUD-151 freshness line: a content change
// followed by a build is reflected in search. The deploy path is exactly this —
// deploy/deploy.sh runs `make dist` before it stages a release, and the build
// rewrites dist/search.db from content/ every time (search.Build removes the old
// file), so search can never serve an index from a previous content tree.
func TestSearchIndexFollowsContent(t *testing.T) {
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
contentDir := filepath.Join(dir, "content")
if err := copyTree(filepath.Join(root, "content"), contentDir); err != nil {
t.Fatalf("copy content: %v", err)
}
out := filepath.Join(dir, "dist")
build := func() {
t.Helper()
s, err := New(Config{Root: root, ContentDir: contentDir, OutDir: out})
if err != nil {
t.Fatalf("load site: %v", err)
}
if err := s.Build(); err != nil {
t.Fatalf("build: %v", err)
}
}
query := func(q string) []search.Result {
t.Helper()
idx, err := search.Open(filepath.Join(out, IndexFile))
if err != nil {
t.Fatalf("open index: %v", err)
}
defer idx.Close()
results, err := idx.Query(q, 20)
if err != nil {
t.Fatalf("query %q: %v", q, err)
}
return results
}
const token = "kumquatprotocol"
build()
if got := query(token); len(got) != 0 {
t.Fatalf("token is already indexed in %d results; the test proves nothing", len(got))
}
page := filepath.Join(contentDir, "edge", "edge-fleet.md")
raw, err := os.ReadFile(page)
if err != nil {
t.Fatal(err)
}
added := string(raw) + "\n\nA " + token + " harness keeps the nodes in step.\n"
if err := os.WriteFile(page, []byte(added), 0o644); err != nil {
t.Fatal(err)
}
build()
results := query(token)
if len(results) == 0 {
t.Fatal("content change is not in the rebuilt index")
}
if results[0].URL != "/edge/edge-fleet/" {
t.Errorf("token found at %s, want /edge/edge-fleet/", results[0].URL)
}
}
// TestImagesReserveTheirSpace is the layout-shift guard: every image a content
// page carries must declare its intrinsic size and load lazily, so a page of
// diagrams and screenshots never reflows as the files arrive.
func TestImagesReserveTheirSpace(t *testing.T) {
s := newSite(t)
seen := 0
for _, d := range s.Content.Docs {
html := d.HTML
for i := 0; ; i++ {
start := strings.Index(html, "<img ")
if start < 0 {
break
}
end := strings.Index(html[start:], ">")
if end < 0 {
break
}
tag := html[start : start+end+1]
html = html[start+end+1:]
seen++
for _, want := range []string{`width="`, `height="`, `loading="lazy"`, `alt="`} {
if !strings.Contains(tag, want) {
t.Errorf("%s: image tag missing %s: %s", d.Path, want, tag)
}
}
}
}
if seen == 0 {
t.Fatal("no images found in content; the guard is not exercising anything")
}
}

View File

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

After

Width:  |  Height:  |  Size: 761 B

View File

@@ -151,13 +151,22 @@ samp {
/* ---- chrome ------------------------------------------------------------ */
/*
Two rows: the name and the search on the first, the six areas plus the index
on the second. The area names are long and the marks made them longer, so a
single row either overlapped the search form or scrolled the nav out of
reach. Two rows give every link a real target at every width.
*/
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: grid;
grid-template-columns: var(--nav-w) minmax(0, 1fr) auto;
gap: var(--pad);
grid-template-columns: var(--nav-w) minmax(0, 1fr);
grid-template-areas:
"brand search"
"nav nav";
gap: 0.5rem var(--pad);
align-items: center;
padding: 0.7rem var(--pad);
background: color-mix(in srgb, var(--bg) 92%, transparent);
@@ -165,6 +174,21 @@ samp {
border-bottom: 1px solid var(--line);
}
.brand {
grid-area: brand;
}
.nav-search {
grid-area: search;
justify-self: end;
width: min(28rem, 100%);
}
.nav-rail {
grid-area: nav;
min-width: 0;
}
.brand {
display: flex;
align-items: center;
@@ -241,17 +265,25 @@ samp {
box-shadow: inset 0 -2px 0 var(--accent);
}
.nav-gutter {
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.08em;
/* The area mark replaces the old two-letter badge. It is decoration; the nav
label beside it is the accessible name. */
.nav-mark {
display: inline-flex;
align-items: center;
color: var(--text-muted);
}
.nav-current .nav-gutter {
.nav-link:hover .nav-mark,
.nav-current .nav-mark {
color: var(--accent);
}
.area-icon {
display: block;
width: 20px;
height: 20px;
}
/* ---- shell ------------------------------------------------------------- */
.shell {
@@ -564,8 +596,9 @@ samp {
color: var(--text-muted);
}
.pillar-gutter {
font-family: var(--font-mono);
.pillar-mark {
display: inline-flex;
align-items: center;
color: var(--accent);
}
@@ -649,8 +682,11 @@ samp {
list-style: none;
}
.tech li {
padding: 0.12rem 0.45rem;
.tech-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.14rem 0.45rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
@@ -658,26 +694,65 @@ samp {
color: var(--text-dim);
}
.honesty {
padding: 1.2rem 1.4rem;
background: var(--surface);
/* Monochrome and currentColor, so the chip and the mark share one colour and
no icon ever adds a request. */
.tech-mark {
display: block;
flex: none;
width: 16px;
height: 16px;
fill: currentColor;
opacity: 0.85;
}
.tech-chip:hover {
border-color: var(--line-strong);
color: var(--text);
}
.tech-chip:hover .tech-mark {
opacity: 1;
}
/* ---- client wall ------------------------------------------------------- */
/*
One typeface, one size, one tile. Mixed brand marks would read as broken;
a consistent grid reads as deliberate. Tiles are the same height whatever
the length of the name, so the wall never looks like a failed image load.
*/
.client-wall {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
gap: 1px;
margin: 1rem 0 1.6rem;
padding: 1px;
background: var(--line);
border: 1px solid var(--line);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
border-radius: var(--radius-sm);
overflow: hidden;
list-style: none;
}
.honesty h2 {
margin-top: 0;
font-size: 1.05rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
.client-tile {
display: flex;
align-items: center;
justify-content: center;
min-height: 4.2rem;
padding: 0.6rem 0.75rem;
background: var(--surface);
text-align: center;
}
.honesty ul {
margin: 0;
padding-left: 1.1rem;
.client-name {
font-size: 0.88rem;
line-height: 1.25;
letter-spacing: 0.01em;
color: var(--text-dim);
text-wrap: balance;
}
.client-tile:hover .client-name {
color: var(--text);
}
.doc-head {
@@ -909,6 +984,15 @@ samp {
color: var(--text-dim);
}
.search-areas {
margin: 0.6rem 0 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1.1rem;
}
.results {
margin: 0;
padding: 0;
@@ -1289,7 +1373,6 @@ figure.media figcaption {
.hero,
.pillar,
.result,
.honesty,
.drill-list li,
.stage,
.prose blockquote {
@@ -1314,6 +1397,29 @@ figure.media figcaption {
color: #222;
}
/* The wall and the chips are surfaces, not decoration: on paper they go
white with a hairline, and the marks keep their shape in one ink. */
.client-wall {
background: #bbb;
border-color: #bbb;
}
.client-tile,
.tech-chip {
background: #fff;
border-color: #bbb;
}
.client-name,
.tech-chip {
color: #111;
}
.nav-mark,
.pillar-mark {
color: #000;
}
a {
color: #000;
border-bottom: 0;

View File

@@ -17,6 +17,7 @@ templ Layout(v LayoutView) {
<title>{ v.Title }</title>
<meta name="description" content={ v.Description }/>
<meta name="color-scheme" content="dark"/>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
<link rel="stylesheet" href="/static/site.css"/>
<link rel="stylesheet" href="/static/chroma.css"/>
<script src="/static/htmx.min.js" defer></script>
@@ -68,7 +69,8 @@ templ Nav(items []NavItem, current string) {
templ navLinks(items []NavItem, current string) {
for _, it := range items {
<a href={ templ.SafeURL(it.Href) } class={ navClass(it) } aria-current={ currentAttr(it) }>
<span class="nav-gutter" aria-hidden="true">{ it.Gutter }</span>
<span class="nav-mark">@AreaIcon(it.Area)
</span>
<span class="nav-title">{ it.Title }</span>
</a>
}
@@ -76,7 +78,8 @@ templ navLinks(items []NavItem, current string) {
templ evidenceLink(current string) {
<a href="/evidence/" class={ navClassFor(current == "evidence") }>
<span class="nav-gutter" aria-hidden="true">EV</span>
<span class="nav-mark">@AreaIcon("evidence")
</span>
<span class="nav-title">Evidence index</span>
</a>
}
@@ -101,17 +104,43 @@ templ Breadcrumbs(crumbs []Crumb) {
}
}
// SearchForm is a GET form: it works with JavaScript disabled and is upgraded by
// HTMX when scripts are available.
// SearchForm is the plain GET form used in the navigation and the landing page.
// It navigates to /search and carries no htmx attributes, because #search-results
// only exists on the search page: an hx-target that is not in the document makes
// htmx discard the response, which is a form that looks like it works and does
// nothing. A plain GET works from every page and with JavaScript disabled.
templ SearchForm(q string, class string) {
<form class={ "search", class } action="/search" method="get" role="search"
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"/>
<form class={ "search", class } action="/search" method="get" role="search">
@searchField(q, class)
<button type="submit">Search</button>
</form>
}
// SearchFormLive is the search page's own form. HTMX swaps the results block in
// place, which is safe here and only here: #search-results is part of the page.
templ SearchFormLive(q string, class string) {
<form
class={ "search", class }
action="/search"
method="get"
role="search"
hx-get="/search"
hx-target="#search-results"
hx-swap="innerHTML"
hx-push-url="true"
>
@searchField(q, class)
<button type="submit">Search</button>
</form>
}
// searchField is the label and input both forms share. The id is derived from the
// form class, so two forms on one page never collide.
templ searchField(q string, class string) {
<label class="visually-hidden" for={ "q-" + class }>Search the site</label>
<input id={ "q-" + class } type="search" name="q" value={ q } placeholder="Search — try “zigbee”, “signage”, “routing”" autocomplete="off"/>
}
// Stage is the presentation panel. Everything about how it is fed comes from
// the binding: route mode swaps in pre-rendered fragments, stream mode listens
// on an event stream. The markup is identical either way.
@@ -152,7 +181,8 @@ templ FragmentView(f stage.Fragment) {
</header>
switch f.Kind {
case stage.KindText:
<div class="frag-text">@f.Body</div>
<div class="frag-text">@f.Body
</div>
case stage.KindCode:
if f.Code != nil {
<figure class="code">
@@ -237,7 +267,8 @@ templ EvidenceTable(rows []EvidenceRow) {
templ PillarCard(c PillarCardView) {
<article class="pillar">
<p class="pillar-kicker">
<span class="pillar-gutter" aria-hidden="true">{ c.Area.Gutter }</span>
<span class="pillar-mark">@AreaIcon(c.Area.Key)
</span>
if c.Area.Role != "" {
<span>{ c.Area.Role }</span>
}
@@ -257,20 +288,9 @@ templ PillarCard(c PillarCardView) {
</ul>
}
if len(c.Tech) > 0 {
<ul class="tech" aria-label="Technology">
for _, t := range c.Tech {
<li>{ t }</li>
}
</ul>
@TechChips(c.Tech)
}
<p class="pillar-foot">
<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>
@@ -301,15 +321,10 @@ templ CodeBlock(c stage.Code) {
</figure>
}
// Footer closes the page with the honesty notes a recruiter needs.
// Footer closes the page.
templ Footer(v LayoutView) {
<footer class="footer">
<p class="footer-line">Twenty-five years shipping software · Melbourne · remote · hybrid · office · travel</p>
<p class="footer-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>

View File

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

View File

@@ -17,23 +17,11 @@ templ HomePage(v HomeView) {
</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>
}
}
@@ -42,7 +30,6 @@ 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">
@@ -58,47 +45,42 @@ templ DocPage(v DocView) {
}
</div>
if len(v.Tech) > 0 {
<ul class="tech" aria-label="Technology">
for _, t := range v.Tech {
<li>{ t }</li>
}
</ul>
@TechChips(v.Tech)
}
</header>
<div class="prose">
@v.Body
</div>
if len(v.Evidence) > 0 {
// Level 1 is one screen: the claim, the figures, the technology and the
// link in the prose. The evidence table starts at level 2, which is the
// page that argues for the claim.
if v.Doc.Level >= 2 && (len(v.Evidence) > 0 || len(v.SubPages) > 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))
<h2 id="evidence-heading">Artefacts</h2>
if len(v.Evidence) > 0 {
@EvidenceTable(evidenceRows(v.Evidence))
}
if len(v.SubPages) > 0 {
<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.Timeline) > 0 {
if v.Doc.Level == 2 && 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>
@@ -109,10 +91,8 @@ templ DocPage(v DocView) {
</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>
<p>Last verified { v.Doc.LastVerified } · <code>content/{ v.Doc.Path }</code></p>
</footer>
</article>
}
@@ -148,40 +128,67 @@ templ SearchPage(v SearchView) {
<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")
@SearchFormLive(v.Query, "page-search")
@SearchResults(v)
</article>
}
}
// SearchResults is swapped into #search-results by HTMX.
// SearchResults is the results block as a page element: the live region wrapper
// plus its contents, which is what the whole page renders. Without JavaScript the
// form reloads the page and this markup arrives in the document.
templ SearchResults(v SearchView) {
<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>
}
@SearchResultsBody(v)
</div>
}
// SearchResultsBody is the results block without the live region. This is what an
// htmx request returns: the search page's form swaps it into #search-results with
// innerHTML, so returning the wrapper too would nest a second element with the
// same id inside the first and make the live region announce twice.
//
// Everything here arrives inside the polite live region, and the count carries
// role="status" on top of that, so the number is read as one utterance instead of
// a run of result titles.
templ SearchResultsBody(v SearchView) {
if v.Query == "" {
<p class="search-empty">Type one or two words above and press Enter. Good starting points: “zigbee”, “signage”, “routing”, “backups”, “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">Nothing found for <q>{ v.Query }</q>. Try a shorter or more general word, or browse an area:</p>
@searchAreaLinks(v.Areas)
} else {
<p class="search-count" role="status">{ 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>
}
}
// searchAreaLinks is the way out of an empty result: the six areas plus the
// evidence index, so a failed search never leaves the reader at a dead end.
templ searchAreaLinks(areas []LinkView) {
<ul class="search-areas">
for _, a := range areas {
<li><a href={ templ.SafeURL(a.Href) }>{ a.Label }</a></li>
}
<li><a href="/evidence/">Evidence index</a></li>
</ul>
}
// 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) {
@@ -193,15 +200,21 @@ templ StreamPage(v StreamView) {
<p class="doc-claim">This page renders the same layout and the same panel as every other page. Its panel is bound to an event stream instead of pre-rendered files, and no layout code changed.</p>
</header>
<div class="prose">
<p>The presentation panel is a content sink. In Mode A it takes pre-rendered fragments over
<p>
The presentation panel is a content sink. In Mode A it takes pre-rendered fragments over
HTMX: you click a row in an evidence table and one file is swapped in. On this page the same
panel is bound to <code>/api/stage/stream</code> and receives { strconv.Itoa(v.Frames) } fragments
over server-sent events, one after another.</p>
<p>Fragments are pushed, not requested. Nothing about the panel — its markup, its fragment
over server-sent events, one after another.
</p>
<p>
Fragments are pushed, not requested. Nothing about the panel — its markup, its fragment
view, its empty state — knows the difference. That is the point: the Phase 5 console
re-points this panel at retrieval output and the layout does not change.</p>
<p class="section-note">This demonstration streams the artefacts already built for the site, with
no model in the loop. It exists to prove the transport, not to pretend an AI is answering.</p>
re-points this panel at retrieval output and the layout does not change.
</p>
<p class="section-note">
This demonstration streams the artefacts already built for the site, with
no model in the loop. It exists to prove the transport, not to pretend an AI is answering.
</p>
</div>
if len(v.Fragment) > 0 {
<section class="doc-sub">
@@ -246,9 +259,3 @@ func evidenceRows(links []LinkView) []EvidenceRow {
return rows
}
func subHeading(level int) string {
if level == 1 {
return "Drill-downs in this area"
}
return "Artefacts behind this page"
}

View File

@@ -23,7 +23,7 @@ import (
type NavItem struct {
Title string
Href string
Gutter string
Area string
Current bool
}
@@ -124,18 +124,16 @@ type (
// 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
Layout LayoutView
Doc *content.Document
Role string
Body templ.Component
Metrics []MetricView
Tech []string
Evidence []LinkView
Backlinks []LinkView
SubPages []LinkView
Timeline []TimelineItem
}
// EvidenceView is the index of every artefact.
@@ -153,6 +151,9 @@ type (
Total int
Indexed int
Failed bool
// Areas are the six area landings, offered as the way out of an
// empty result. Nothing here was ever a search hit.
Areas []LinkView
}
// StreamView is the SSE-driven stage demonstration page.
@@ -197,33 +198,3 @@ func ConfidenceClass(c string) string {
}
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 ""
}

46
platforms/gitea.yml Normal file
View File

@@ -0,0 +1,46 @@
# Platform guide — Gitea.
#
# This file is both configuration and public copy. The site shows it so a
# visitor can see exactly what the assistant reads. Keep the wording honest.
name: gitea
enabled: true
what: >-
Self-hosted Git. The source of truth for every current project: README files,
file trees, selected source files, recent commit messages and language
statistics. This is where the work itself lives.
fits: [ai, platform, edge, products]
base: https://gitea.lab.audasmedia.com.au
auth: GITEA_TOKEN
include:
repos: all
# No path include means: every text file the repository rules admit.
paths: []
exclude:
repos:
# The vault is read by the obsidian guide, so its notes are not harvested
# twice under a different platform.
- sam/obsidian-vault
paths:
- "**/node_modules/**"
- "**/.venv/**"
- "**/venv/**"
- "**/.git/**"
- "**/.trash/**"
- "**/.obsidian/**"
- "**/.pi/**"
- "**/__pycache__/**"
- "**/graphify-out/**"
- "**/graphify-out-temp/**"
- "**/vendor/**"
- "**/third_party/**"
- "**/*.min.js"
- "**/*.min.css"
- "**/*.lock"
read: [readme, tree, selected_source, commits, languages]
public: true
limits:
max_documents: 4000
max_file_kb: 200
max_files_per_repo: 40
max_commits: 20
max_tree_entries: 3000

23
platforms/graphify.yml Normal file
View File

@@ -0,0 +1,23 @@
# Platform guide — Graphify knowledge graph.
#
# This file is both configuration and public copy.
name: graphify
enabled: true
what: >-
A knowledge graph over the notes. It answers "what connects X to Y" rather
than "what mentions X": nodes are the things in the estate, edges are the
relations between them, and every one points back at the note it came from.
fits: [ai]
base: sam/obsidian-vault
read: [graph_json]
public: true
limits:
max_documents: 4000
# graph.json is the only file this platform reads. These are the places it may
# be committed; the first match wins.
graph_paths:
- graphify-out/graph.json
- graph.json
- Graph/graph.json
max_nodes: 20000
max_edges: 40000

44
platforms/obsidian.yml Normal file
View File

@@ -0,0 +1,44 @@
# Platform guide — Obsidian vault (sam/obsidian-vault, read through Gitea).
#
# This file is both configuration and public copy.
#
# Privacy note: only project, area and index notes are read. The personal and
# family folders (000 daily, 400 Personal Family, 500 archive) and the templates
# are deliberately out of scope. They are not work evidence and they contain
# third-party personal detail. See docs/content-redaction.md.
name: obsidian
enabled: true
what: >-
A personal knowledge base. High-level notes on every system, in Sam's own
words: what a system is for, what changed, and what went wrong. Project and
area notes only; personal, family and daily notes are not read.
fits: [about, ai, platform, edge, products]
base: sam/obsidian-vault
include:
paths:
- "010 inbox/**"
- "100 Table of Contents/**"
- "200 projects/**"
- "300 areas/**"
exclude:
paths:
- ".obsidian/**"
- ".trash/**"
- ".venv/**"
- "templates/**"
- "000 daily/**"
- "400 Personal Family/**"
- "500 archive/**"
- "**/*.excalidraw"
- "**/*.png"
- "**/*.jpg"
- "**/*.jpeg"
- "**/*.gif"
- "**/*.webp"
- "**/*.pdf"
- "**/*Bedroom Lights*"
read: [notes]
public: true
limits:
max_documents: 4000
max_file_kb: 200

View File

@@ -15,7 +15,7 @@
| Service | Host | Port | State |
|---|---|---|---|
| Caddy | `.35` | 443 (+ LAN 8000) | existing — **Sam applies any change** |
| **Go app** | `.13` | **8096** | new — verify free (`8095` = offsite.lab) |
| **Go app** | `.13` | **8096** | new — verify free (`8095` = offsite.lab); published on `192.168.20.13:8096`, **never** `0.0.0.0` |
| Qdrant REST | `.13` | 6333 | new, `127.0.0.1` only |
| Qdrant gRPC | `.13` | 6334 | new, `127.0.0.1` only |
| Prefect server | `.13` | existing | reuse — do not deploy a second |
@@ -55,7 +55,9 @@ Ceiling: 20,000 chunks at 1024 dimensions. Beyond that, set
`RESUME_QDRANT_VECTORS_ON_DISK=1`, reduce the dimension, or raise the cap — in
that order. Detail: `docs/phase-4-notes.md` §7.
Every service sets `mem_limit` **and** `memswap_limit` to the same value so it cannot consume host swap. Bind to `127.0.0.1`; Caddy is the only public listener.
Every service sets `mem_limit` **and** `memswap_limit` to the same value so it cannot consume host swap. Bind services to `127.0.0.1`; Caddy on `.35` is the only public listener.
**One documented amendment (Go app, port 8096):** the app publishes on the LAN address `192.168.20.13:8096`, not `127.0.0.1`. Caddy runs on `.35` and proxies to `192.168.20.13:8096`, so a loopback-only publish is unreachable from the edge and the public URL returns 502. This is the same way `8095` (offsite), `8092` (photo-filter), `3909` (garage-ui) and `4200` (prefect) are published on `.13`. The port is LAN-only — never `0.0.0.0` — and Caddy on `.35` is still the only public listener, so `AGENTS.md` §4.2 holds in substance. Decision: Sam, 2026-10-26, interaction `54aeb1a2`. `deploy/deploy.sh` follows the same address (`RESUME_PUBLISH_IP`, default `192.168.20.13`) in its health gate and shape assertion.
## Paths

71
tools/gen_brand_icons.sh Executable file
View File

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