3 Commits

Author SHA1 Message Date
Sam Rolfe
6900101506 docs: image brief and treatment tooling
- docs/image-brief.md: one treatment for every image, keyed to the site palette
- tools/images/treat.py: cutout (rembg) and treat (Pillow) as separate, re-runnable steps
- tools/images/README.md: how to run it without polluting the NixOS python
2026-10-29 13:31:20 +11:00
Sam Rolfe
396eeb175e content: car tracker page with a real hardware image
- content/edge/l3/edge-car-tracker.md, written from the evidence notes
- product image of the SIM7080G Cat-M module, cropped to 640px, with a caption
  naming what it is and that it is a product image
- referenced from the edge overview
- original kept in sam/CAR_GPS_TRACKER so the project repo documents its own build
2026-10-29 11:24:26 +11:00
Sam Rolfe
36d8e514d4 content: six real code exhibits, rendered by the panel (AUD-152) 2026-10-28 16:00:06 +11:00
17 changed files with 560 additions and 26 deletions

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" ];
};
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -8,9 +8,9 @@ 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]
---
@@ -30,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 |

View File

@@ -8,7 +8,7 @@ confidence: running
period: "2020–"
tech: [ESP32, LoRa, Zigbee, MQTT, Home Assistant, Snapcast, Raspberry Pi]
metrics: [{label: "edge nodes", value: 20}, {label: "lighting zones", value: 9}, {label: "automations", value: 20}]
artefacts: [{type: doc, label: "Edge architecture — power · radio · deep sleep", url: "content/edge/l3/edge-architecture.md"}, {type: doc, label: "The failures, kept in the record", url: "content/edge/l3/edge-failures.md"}, {type: doc, label: "Distributed audio — eight endpoints with ducking", url: "content/edge/l3/edge-snapcast.md"}]
artefacts: [{type: doc, label: "Edge architecture — power · radio · deep sleep", url: "content/edge/l3/edge-architecture.md"}, {type: doc, label: "The failures, kept in the record", url: "content/edge/l3/edge-failures.md"}, {type: doc, label: "Distributed audio — eight endpoints with ducking", url: "content/edge/l3/edge-snapcast.md"}, {type: doc, label: "Vehicle tracker on LTE Cat-M", url: "content/edge/l3/edge-car-tracker.md"}]
public: true
last_verified: 2026-10-26
tags: [edge, iot, esp32, lora, zigbee]

View File

@@ -0,0 +1,51 @@
---
id: edge-car-tracker
area: edge
level: 3
title: "Artefact — Vehicle tracker on LTE Cat-M"
claim: "A tracker that sleeps between reports · wakes on a schedule or when the car moves · and reports when it leaves a boundary set from the web interface."
confidence: in-progress
period: "2024–"
tech: [ESP32, LTE Cat-M, NB-IoT, SIM7080G, GPS, LiFePO4]
metrics: [{label: "battery packs", value: 2}]
artefacts: []
public: true
last_verified: 2026-10-28
tags: [edge, tracker, lte, low-power, artefact]
---
The build is on the bench: two LiFePO batteries, a buck converter, an ESP32 with GPS, and a
cellular module for the uplink. The car keeps the packs charged while it is running.
![Waveshare Pico SIM7080G Cat-M and NB-IoT module — the cellular link in the tracker. Product image, used to identify the part.](../../assets/images/sim7080g-module.jpg)
## What it does
Two behaviours, chosen by the settings rather than hard-coded. When the car is parked the
ESP32 sits in deep sleep, wakes on a timer, checks its own configuration, and reports only
what the settings ask for. When the car is moving it stays awake and reports on a shorter
interval. Either way the interval and the boundary can be changed from the web interface,
and a change can force an immediate update rather than waiting for the next wake.
The geofence is the part that matters. A boundary is drawn from the web interface, and the
device is responsible for deciding whether it has left it — which is why the settings have
to travel to the device rather than live only in the server.
## Why LTE Cat-M rather than WiFi
The tracker lives in a car. WiFi coverage is a driveway, not a road. Cat-M and NB-IoT were
chosen because they are designed for exactly this shape of traffic — small payloads, long
sleeps, coverage beyond what 4G handsets need — and because the modules are cheap enough to
leave installed.
## What it proves
A complete path rather than a demo: constrained hardware, a power budget that dictates the
schedule, a device that holds enough state to act on its own, and a web application that
configures it and shows where it has been.
## Still open
Field testing across the full battery cycle. The sleep interval that is right for a parked
car and the interval that is right for a moving one are guesses until the discharge curve
has been measured over a week rather than a day.

View File

@@ -8,26 +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]
---
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
@@ -35,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

@@ -8,13 +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]
---
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
@@ -24,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,9 +8,9 @@ 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]
---
@@ -32,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 |

View File

@@ -8,13 +8,13 @@ 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]
---
Retired as a general-purpose product; kept as the content engine for the signage.
**Status: delivered, retained.** Retired as a general-purpose product; kept as the content engine for the signage.
## What the artefact is
@@ -24,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.

80
docs/image-brief.md Normal file
View File

@@ -0,0 +1,80 @@
# Image brief
Every image on this site gets the same treatment. Consistency is what makes a set look
designed; six images treated six ways look worse than six treated identically.
The palette is fixed by `internal/web/static/site.css`:
| Token | Value | Use |
|---|---|---|
| `--bg` | `#0b0d10` | canvas and fade colour. Never pure black |
| `--surface` | `#12161b` | screenshot backgrounds |
| `--line` | `#242c35` | screenshot borders |
| `--line-strong` | `#33404d` | diagram strokes |
| `--text-dim` | `#b3bcc8` | diagram labels |
| `--link` | `#8ecbff` | rim glow |
| `--accent` | `#6ee7c6` | sparing emphasis only |
## Why white-background product shots fail here
`#FFFFFF` on a `#0b0d10` page is roughly twenty times the brightness of everything around
it. The brightest thing in the frame becomes the backdrop rather than the object, and the
eye lands on the backdrop first. One reads as an accident; six read as a scrapbook.
## Photographs
- **Canvas** 1600×1000 for hero images, 1200×1200 for detail. Landscape unless the object demands otherwise.
- **Background** `#0b0d10`, the page colour. Not black — pure black always looks like a void.
- **Subject** cut out, placed right of centre, occupying 55–70% of the canvas height. Off-centre because the fade comes from the left.
- **Fade** a `#0b0d10` gradient, 100% opacity at the left edge to 0% at about 55% across. The object emerges from the page and dissolves to the right. This is the move that stops an image looking pasted on.
- **Rim glow** `#8ecbff` at 25–40% opacity, 8–14px blur, derived from the cut-out's own alpha channel so it follows real edges. Enough to lift the object off the background. Not enough to look like a gaming mouse.
- **Shadow lift** 5–8% on the subject itself. Cut-outs on dark backgrounds lose their dark detail and read as flat stickers otherwise.
- **Format** WebP, quality 82, plus a JPEG fallback. Target under 120 KB for a hero.
## Screenshots
Never cut out. Frame them like the site's own panels:
- corner radius `10px` (`--radius`)
- 1px border in `--line`
- edges darkened 8%
- soft drop shadow
- crop out browser chrome where possible
## Diagrams
Stay SVG and stay in the palette: `--line-strong` strokes, `--text-dim` labels, `--accent`
and `--link` for emphasis. No white fills, no default Mermaid blues.
## Logos
One colour, inheriting `currentColor` from the surrounding text. Forty-six client logos in
full colour would shriek. If one deserves emphasis, give it `--accent`; the rest stay muted.
## Prohibited
White boxes · stock photography · lens flares · 3D renders of objects not owned ·
glow strong enough to be the point · any image containing a family member, an address,
a credential, or a screen showing personal data.
## Capture guidance
Treatment rescues a product shot; it cannot rescue a photograph of a white wall in daylight.
- dark surface, one light source from one side
- shoot slightly above the object
- keep the object off-centre in frame
- no overhead lighting, no visible background clutter
- a phone is fine
Rack and cabinet shots are the exception: keep them whole and wide, framed like
screenshots rather than cut out.
## Output contract
Every image lands in `content/assets/images/`, named lowercase and hyphenated with no
spaces or dates. Every image used on a page carries a caption naming the object; a product
photograph additionally states that it is a product image and where it came from. The
original, if it is Sam's own photograph or a sourced product image, is also filed in the
repository of the project it belongs to, under `docs/images/`, with provenance recorded in
that folder's `README.md`.

35
tools/images/README.md Normal file
View File

@@ -0,0 +1,35 @@
# Image treatment
Implements `docs/image-brief.md`. Two steps, in this order.
## 1. Cut the subject out
Needs `rembg`. It is not installed on the host, and installing Python packages on NixOS
into the system interpreter is a bad idea. Use a container:
```
docker run --rm -v "$PWD/raw:/in" -v "$PWD/build/cut:/out" \
-v "$PWD/tools/images/treat.py:/treat.py" \
python:3.12-slim sh -c "pip install -q rembg[cpu] pillow onnxruntime && \
python /treat.py cutout /in/*.jpg --out /out"
```
Or cut out by hand in GIMP and skip to step 2. For fewer than ten images, hand-cutting is
usually better — automatic segmentation struggles with reflective or thin edges.
## 2. Apply the treatment
```
pip install pillow # or the same container, without rembg
python3 tools/images/treat.py treat build/cut/*.png --size hero
```
Output lands in `content/assets/images/` as WebP at quality 82, canvas `#0b0d10`, subject
right of centre, left-to-right fade, rim glow from the alpha channel.
## Then
1. Add the page reference: `![caption](../../assets/images/name.webp)`
2. File the original in the project's own repo under `docs/images/`, with a note in that
folder's `README.md` saying where it came from.
3. Deploy and check it on the live site at 1440×900 and on a phone.

97
tools/images/treat.py Executable file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Apply the site image treatment described in docs/image-brief.md.
Two jobs, both idempotent and re-runnable:
cutout remove the background from a product photograph (needs rembg)
treat compose onto the dark canvas with the fade and glow (needs Pillow)
Typical use:
python3 tools/images/treat.py cutout raw/*.jpg -> build/cut/*.png
python3 tools/images/treat.py treat build/cut/*.png -> content/assets/images/*.webp
If rembg is not available, cut the subject out by hand in GIMP, save as PNG with
transparency, and run only the `treat` step.
"""
import argparse, pathlib, sys
BG = (0x0b, 0x0d, 0x10) # --bg
GLOW = (0x8e, 0xcb, 0xff) # --link
SIZES = {"hero": (1600, 1000), "detail": (1200, 1200)}
FADE_END = 0.55 # gradient reaches 0 opacity here
GLOW_A = 0.32
GLOW_BLUR = 11
LIFT = 1.06 # shadow lift on the subject
def cutout(paths, outdir):
try:
from rembg import remove
except ImportError:
sys.exit("rembg not installed. See tools/images/README.md, or cut out by hand and skip this step.")
from PIL import Image
outdir.mkdir(parents=True, exist_ok=True)
for p in paths:
src = Image.open(p).convert("RGBA")
dst = remove(src)
target = outdir / (pathlib.Path(p).stem + ".png")
dst.save(target)
print(f"cut {p} -> {target}")
def treat(paths, outdir, size="hero"):
from PIL import Image, ImageFilter, ImageEnhance
W, H = SIZES[size]
outdir.mkdir(parents=True, exist_ok=True)
for p in paths:
subject = Image.open(p).convert("RGBA")
# fit to 68% of canvas height, keep aspect
scale = (H * 0.68) / subject.height
subject = subject.resize((max(1, int(subject.width * scale)), int(H * 0.68)), Image.LANCZOS)
# shadow lift
rgb = ImageEnhance.Brightness(subject.convert("RGB")).enhance(LIFT)
subject = Image.merge("RGBA", (*rgb.split(), subject.split()[3]))
canvas = Image.new("RGB", (W, H), BG)
# place right of centre
x = int(W * 0.52)
y = (H - subject.height) // 2
canvas.paste(subject, (x, y), subject)
# rim glow from the subject's own alpha
alpha = subject.split()[3].filter(ImageFilter.GaussianBlur(GLOW_BLUR))
glow = Image.new("RGB", subject.size, GLOW)
canvas.paste(glow, (x, y), alpha.point(lambda a: int(a * GLOW_A / 255)))
# left-to-right fade back to the page colour
fade = Image.new("L", (W, 1))
end = int(W * FADE_END)
for px in range(W):
fade.putpixel((px, 0), 255 if px < end else max(0, int(255 * (1 - (px - end) / max(1, W - end)))))
fade = fade.resize((W, H))
canvas = Image.composite(Image.new("RGB", (W, H), BG), canvas, fade)
target = outdir / (pathlib.Path(p).stem + ".webp")
canvas.save(target, "WEBP", quality=82, method=6)
kb = target.stat().st_size / 1024
print(f"treat {p} -> {target} ({kb:.0f} KB)")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("step", choices=["cutout", "treat"])
ap.add_argument("paths", nargs="+", type=pathlib.Path)
ap.add_argument("--out", type=pathlib.Path, default=None)
ap.add_argument("--size", choices=list(SIZES), default="hero")
a = ap.parse_args()
out = a.out or pathlib.Path("build" if a.step == "cutout" else "content/assets/images")
(cutout if a.step == "cutout" else treat)(a.paths, out) if a.step == "cutout" else treat(a.paths, out, a.size)
if __name__ == "__main__":
main()