vitrine

theming: hostname-derived accent color, theme name in nav

commit 8921cae64f3d55cfb6f03ad1bcb0e43e981cdc1b · parent ca414d4fd8 · browse this tree
Ximianth, 2026-07-30 11:08:08 UTC

Each host hashes its hostname's first dot-segment (FNV-1a mod 360) to a hue,
and /-/style.css now prepends a computed :root { --accent: ... } block
ahead of the embedded stylesheet, winning by cascade order.
The rest of the stylesheet already flowed from --accent, so nothing else moves.

The theme name appears beside the site name in the nav breadcrumb,
so color never carries the host identity alone (colorblind users; similar hues).
-theme-seed overrides the hostname segment.

The accent colors text on the near-white paper background,
and hsl(H 70% 35%) fails WCAG AA (4.5:1) for hues 41-191 (yellow-green-cyan),
so themeLightness drops that band (with margin: 35-200) to 26% lightness;
worst case over the wheel is then ~5.06:1.  Contrast figures measured
against #fdfdfc with the standard WCAG relative-luminance formula.

Hue pins for known seeds are in theme_test.go and are load-bearing:
changing the derivation silently repaints every deployed host.

Note: the plan said the hostname's 'last' dot-segment, but its own example
(langoustine from langoustine.exe.xyz) is the first segment,
which is the machine-distinct one by DNS convention; first is what's implemented.
diff --git a/README.md b/README.md
index a3478a2..35961af 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,21 @@ a reload that fails to parse, or names a directory that can't be opened,
 logs a loud REJECTED line and the previous exhibits keep serving untouched.
 Edit freely; a typo mid-edit can't take the server dark.
 
+Theming
+-------
+
+Each host wears its own accent color, derived from its hostname,
+so when several vitrines from several hosts are open in tabs,
+each is recognizable at a glance.
+The hostname's first dot-segment (e.g. `langoustine` from `langoustine.exe.xyz`)
+hashes to a hue; that name is also shown next to the site name in the nav,
+so the color never has to carry the meaning alone.
+
+The derivation is deterministic: the same hostname always yields the same color,
+on every build and every host.
+The `-theme-seed` flag substitutes any string for the hostname segment --
+useful for testing, or if you dislike the color your hostname landed on.
+
 expose-git
 ----------
 
diff --git a/main.go b/main.go
index a8ad6d4..f1003a5 100644
--- a/main.go
+++ b/main.go
@@ -13,22 +13,32 @@ import (
 	"flag"
 	"log"
 	"net/http"
+	"os"
 	"time"
 )
 
 func main() {
 	addr := flag.String("addr", ":8000", "listen address")
 	configPath := flag.String("config", "", "path to config file (required)")
+	themeSeed := flag.String("theme-seed", "", "theme name override (default: first dot-segment of the hostname)")
 	flag.Parse()
 
 	if *configPath == "" {
 		log.Fatal("the -config flag is required; there is no default config location")
 	}
+	seed := *themeSeed
+	if seed == "" {
+		hostname, err := os.Hostname()
+		if err != nil {
+			log.Fatalf("cannot determine hostname for theming (use -theme-seed to bypass): %v", err)
+		}
+		seed = hostLabel(hostname)
+	}
 	cfg, err := loadConfig(*configPath)
 	if err != nil {
 		log.Fatal(err)
 	}
-	srv, err := newServer(cfg.Exhibits)
+	srv, err := newServer(cfg.Exhibits, newTheme(seed))
 	if err != nil {
 		log.Fatal(err)
 	}
diff --git a/render.go b/render.go
index 94acbff..333f97f 100644
--- a/render.go
+++ b/render.go
@@ -41,12 +41,13 @@ type crumb struct {
 }
 
 type pageData struct {
-	Title   string
-	Crumbs  []crumb
-	RawHref string        // if set, a "raw" link is shown
-	Listing []dirEntry    // directory listing mode
-	Content template.HTML // rendered-markdown mode
-	Iframe  string        // iframe-passthrough mode: src URL
+	Title     string
+	ThemeName string // host theme name, shown beside the site name in the nav
+	Crumbs    []crumb
+	RawHref   string        // if set, a "raw" link is shown
+	Listing   []dirEntry    // directory listing mode
+	Content   template.HTML // rendered-markdown mode
+	Iframe    string        // iframe-passthrough mode: src URL
 }
 
 // joinURL joins a mount name and a tree-relative path into one URL path
@@ -78,7 +79,7 @@ func crumbs(urlpath string) []crumb {
 
 // serveRendered shows a file as a page: markdown rendered to HTML,
 // anything else passed through in an iframe pointed at the raw view.
-func serveRendered(w http.ResponseWriter, r *http.Request, root *os.Root, mount, rel string, size int64) {
+func (s *server) serveRendered(w http.ResponseWriter, r *http.Request, root *os.Root, mount, rel string, size int64) {
 	pd := pageData{
 		Title:   mount + "/" + rel,
 		Crumbs:  crumbs(joinURL(mount, rel)),
@@ -101,7 +102,7 @@ func serveRendered(w http.ResponseWriter, r *http.Request, root *os.Root, mount,
 	} else {
 		pd.Iframe = "?fmt=raw"
 	}
-	renderPage(w, pd)
+	s.renderPage(w, pd)
 }
 
 var pageTmpl = template.Must(template.New("page").Parse(`<!doctype html>
@@ -114,7 +115,7 @@ var pageTmpl = template.Must(template.New("page").Parse(`<!doctype html>
 </head>
 <body>
 <nav>
-{{range $i, $c := .Crumbs}}{{if $i}} / {{end}}{{if $c.Href}}<a href="{{$c.Href}}">{{$c.Name}}</a>{{else}}{{$c.Name}}{{end}}{{end}}
+{{range $i, $c := .Crumbs}}{{if $i}} / {{end}}{{if $c.Href}}<a href="{{$c.Href}}">{{$c.Name}}</a>{{else}}{{$c.Name}}{{end}}{{if and (eq $i 0) $.ThemeName}}<span class="theme">@{{$.ThemeName}}</span>{{end}}{{end}}
 {{if .RawHref}}<a class="raw" href="{{.RawHref}}">raw</a>{{end}}
 </nav>
 {{if .Listing}}<ul class="listing">{{range .Listing}}<li><a href="{{.Href}}">{{.Name}}</a></li>{{end}}</ul>{{end}}
@@ -124,7 +125,9 @@ var pageTmpl = template.Must(template.New("page").Parse(`<!doctype html>
 </html>
 `))
 
-func renderPage(w http.ResponseWriter, pd pageData) {
+// renderPage fills in the server-wide theme name and executes the page template.
+func (s *server) renderPage(w http.ResponseWriter, pd pageData) {
+	pd.ThemeName = s.theme.Name
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 	if err := pageTmpl.Execute(w, pd); err != nil {
 		fmt.Fprintf(w, "\n<!-- template error: %v -->", err)
diff --git a/serve.go b/serve.go
index f4c3d74..b464ed0 100644
--- a/serve.go
+++ b/serve.go
@@ -20,6 +20,7 @@ import (
 // the atomic pointer makes the publish-immutable-snapshots discipline explicit.)
 type server struct {
 	mounts atomic.Pointer[mountSet]
+	theme  theme // fixed for the process lifetime; see theme.go
 }
 
 // mountSet maps mount names (URL prefixes) to opened trees.
@@ -34,8 +35,8 @@ type tree struct {
 	exposeGit bool
 }
 
-func newServer(exhibits map[string]exhibit) (*server, error) {
-	s := &server{}
+func newServer(exhibits map[string]exhibit, th theme) (*server, error) {
+	s := &server{theme: th}
 	if err := s.setExhibits(exhibits); err != nil {
 		return nil, err
 	}
@@ -106,6 +107,9 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	// keeping it distinct from any configured mount name.
 	if r.URL.Path == "/-/style.css" {
 		w.Header().Set("Content-Type", "text/css; charset=utf-8")
+		// The theme's :root block precedes the embedded stylesheet,
+		// so its --accent wins over the stylesheet's default by cascade order.
+		w.Write(s.theme.CSS)
 		w.Write(styleCSS)
 		return
 	}
@@ -139,7 +143,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		if req != "" {
 			title = req + "/"
 		}
-		renderPage(w, pageData{Title: title, Crumbs: crumbs(req), Listing: kids})
+		s.renderPage(w, pageData{Title: title, Crumbs: crumbs(req), Listing: kids})
 		return
 	}
 
@@ -223,7 +227,7 @@ func (s *server) serveFromTree(w http.ResponseWriter, r *http.Request, tr tree,
 		// Rendering .git contents as pages was never meaningful anyway.
 		serveRaw(w, r, root, rel)
 	default:
-		serveRendered(w, r, root, mount, rel, info.Size())
+		s.serveRendered(w, r, root, mount, rel, info.Size())
 	}
 }
 
@@ -269,7 +273,7 @@ func (s *server) serveDir(w http.ResponseWriter, r *http.Request, tr tree, mount
 		}
 		listing = append(listing, dirEntry{Name: n, Href: n})
 	}
-	renderPage(w, pageData{
+	s.renderPage(w, pageData{
 		Title:   mount + "/" + strings.TrimSuffix(rel, "."),
 		Crumbs:  crumbs(joinURL(mount, rel)),
 		Listing: listing,
diff --git a/serve_test.go b/serve_test.go
index 62e2cd1..a6436f7 100644
--- a/serve_test.go
+++ b/serve_test.go
@@ -29,7 +29,7 @@ func newTestServer(t *testing.T) *server {
 			t.Fatal(err)
 		}
 	}
-	srv, err := newServer(map[string]exhibit{"demo": {Path: dir}})
+	srv, err := newServer(map[string]exhibit{"demo": {Path: dir}}, newTheme("testhost"))
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -135,7 +135,7 @@ func newNestedTestServer(t *testing.T) *server {
 	}
 	srv, err := newServer(map[string]exhibit{
 		"meta/research/latest": {Path: filepath.Join(dir, "research/latest")},
-	})
+	}, theme{})
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -222,7 +222,7 @@ func newExposeGitTestServer(t *testing.T) (*server, string) {
 			t.Fatalf("git %v: %v: %s", args, err, out)
 		}
 	}
-	srv, err := newServer(map[string]exhibit{"repo": {Path: dir, ExposeGit: true}})
+	srv, err := newServer(map[string]exhibit{"repo": {Path: dir, ExposeGit: true}}, theme{})
 	if err != nil {
 		t.Fatal(err)
 	}
diff --git a/style.css b/style.css
index 3d630b4..3c8e604 100644
--- a/style.css
+++ b/style.css
@@ -37,6 +37,14 @@ nav .raw {
 }
 nav .raw:hover { background: var(--well); }
 
+/* Host theme name: names the host whose accent color this page wears,
+   so the color never has to carry that meaning alone. */
+nav .theme {
+	font-size: .75rem;
+	color: var(--accent);
+	margin-left: .25rem;
+}
+
 ul.listing { list-style: none; padding-left: 1.5rem; }
 ul.listing li { padding: .15rem 0; }
 
diff --git a/theme.go b/theme.go
new file mode 100644
index 0000000..abb3c74
--- /dev/null
+++ b/theme.go
@@ -0,0 +1,59 @@
+package main
+
+import (
+	"fmt"
+	"hash/fnv"
+	"strings"
+)
+
+// Hostname-derived theming: each host hashes its name to an accent color,
+// so several vitrines open in browser tabs are distinguishable at a glance.
+// The theme name also appears next to the site name in the nav,
+// because color alone must not carry the meaning
+// (colorblind users, and nearby hues look alike anyway).
+
+// theme is one host's visual identity, computed once at startup.
+type theme struct {
+	Name string // the seed itself, shown in the nav next to the site name
+	CSS  []byte // ":root" accent override, prepended to the stylesheet by the /-/style.css handler
+}
+
+func newTheme(seed string) theme {
+	hue := themeHue(seed)
+	return theme{
+		Name: seed,
+		CSS:  []byte(fmt.Sprintf(":root { --accent: hsl(%d 70%% %d%%); }\n", hue, themeLightness(hue))),
+	}
+}
+
+// hostLabel extracts the theme seed from a hostname: the first dot-segment,
+// so both "langoustine" and "langoustine.exe.xyz" yield "langoustine".
+// (The first segment is the machine-distinct one; the tail is shared domain.)
+func hostLabel(hostname string) string {
+	label, _, _ := strings.Cut(hostname, ".")
+	return label
+}
+
+// themeHue hashes a theme seed to a hue in [0, 360) via FNV-1a.
+// Deterministic and pinned by tests:
+// changing this function silently repaints every deployed host.
+func themeHue(seed string) int {
+	h := fnv.New32a()
+	h.Write([]byte(seed))
+	return int(h.Sum32() % 360)
+}
+
+// themeLightness picks the accent lightness (percent) for a hue.
+// The accent is used for text (h1, links) on the near-white --paper background,
+// and at 70% saturation a fixed 35% lightness fails WCAG AA (4.5:1)
+// for the whole yellow-green-cyan band: measured, hues 41-191 fall short,
+// bottoming out around 3.0:1 at hue 60.
+// Dropping that band (with a little margin) to 26% lightness
+// brings the worst case over the whole wheel up to ~5.06:1 (at hue 34, just outside the band);
+// all hues outside the band clear 4.5:1 at 35%.
+func themeLightness(hue int) int {
+	if hue >= 35 && hue <= 200 {
+		return 26
+	}
+	return 35
+}
diff --git a/theme_test.go b/theme_test.go
new file mode 100644
index 0000000..571ee0e
--- /dev/null
+++ b/theme_test.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+	"net/http"
+	"strings"
+	"testing"
+)
+
+// TestThemeHuePinned pins hue values for known seeds.
+// These pins are load-bearing: a refactor that changes the hash or the modulo
+// silently repaints every deployed host, and this test is what catches it.
+func TestThemeHuePinned(t *testing.T) {
+	cases := map[string]int{
+		"langoustine": 180,
+		"vitrine":     72,
+		"alpha":       107,
+		"zebra":       95,
+		"":            61,
+	}
+	for seed, want := range cases {
+		if got := themeHue(seed); got != want {
+			t.Errorf("themeHue(%q) = %d, want %d", seed, got, want)
+		}
+	}
+}
+
+// TestThemeLightnessBand pins the contrast-driven lightness rule
+// at the band edges and representative points; see themeLightness for the derivation.
+func TestThemeLightnessBand(t *testing.T) {
+	cases := map[int]int{
+		0:   35, // reds: fine at 35%
+		34:  35, // last hue before the band
+		35:  26, // band start
+		60:  26, // pure yellow, the worst offender
+		180: 26, // cyan, still in the band
+		200: 26, // band end
+		201: 35, // blues onward: fine at 35%
+		240: 35,
+	}
+	for hue, want := range cases {
+		if got := themeLightness(hue); got != want {
+			t.Errorf("themeLightness(%d) = %d, want %d", hue, got, want)
+		}
+	}
+}
+
+func TestHostLabel(t *testing.T) {
+	cases := map[string]string{
+		"langoustine":         "langoustine",
+		"langoustine.exe.xyz": "langoustine",
+		"a.b":                 "a",
+	}
+	for hostname, want := range cases {
+		if got := hostLabel(hostname); got != want {
+			t.Errorf("hostLabel(%q) = %q, want %q", hostname, got, want)
+		}
+	}
+}
+
+// TestStylesheetCarriesThemeAccent checks that /-/style.css leads with the
+// computed :root override and still contains the embedded stylesheet after it.
+func TestStylesheetCarriesThemeAccent(t *testing.T) {
+	rec := get(t, newTestServer(t), "/-/style.css")
+	if rec.Code != http.StatusOK {
+		t.Fatalf("status = %d", rec.Code)
+	}
+	body := rec.Body.String()
+	// "testhost" is the seed newTestServer uses; it hashes to hue 351, lightness 35%.
+	want := ":root { --accent: hsl(351 70% 35%); }\n"
+	if !strings.HasPrefix(body, want) {
+		t.Errorf("stylesheet does not start with theme override %q; head: %q", want, body[:min(len(body), 120)])
+	}
+	if !strings.Contains(body, "--paper") {
+		t.Errorf("embedded stylesheet missing after theme override")
+	}
+}
+
+// TestNavShowsThemeName checks that pages name the host theme beside the site name.
+func TestNavShowsThemeName(t *testing.T) {
+	rec := get(t, newTestServer(t), "/")
+	if !strings.Contains(rec.Body.String(), `<span class="theme">@testhost</span>`) {
+		t.Errorf("nav missing theme name; body:\n%s", rec.Body.String())
+	}
+}
diff --git a/watch_test.go b/watch_test.go
index 5e6740b..c6d00e8 100644
--- a/watch_test.go
+++ b/watch_test.go
@@ -40,7 +40,7 @@ func newWatchFixture(t *testing.T, pollInterval time.Duration) *watchFixture {
 	if err != nil {
 		t.Fatal(err)
 	}
-	fx.srv, err = newServer(cfg.Exhibits)
+	fx.srv, err = newServer(cfg.Exhibits, theme{})
 	if err != nil {
 		t.Fatal(err)
 	}