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) }