socle

sitename: the published identity comes from config, and the chrome wears it

commit 9d1cce7ca26408c451aae2c492931cbe2e9315e0 · parent 199e3974a0 · browse this tree
Ximianth, 2026-10-30 14:33:02 UTC

The hostname in the header was a misfeature: socle publishes under a name
that need not be the machine's.  A new optional config key 'sitename' is the
displayed identity -- first crumb, index title, footer -- and seeds the theme,
falling back to the baseurl's host label, then the machine hostname.

The themed chrome (theme, sketch assets, stylesheet) moves into the snapshot
as a 'face', because the sitename comes from config and config reloads live;
regeneration is cheap and deterministic.  -theme-seed still recolors without
renaming (and keeps test snapshots machine-independent).

The tool's own name leaves the chrome: no more 'socle @host' in the corner,
and the footer colophon now reads '{sitename} . made with socle'.
diff --git a/README.md b/README.md
index 7daf147..26e9876 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@ socle takes exactly one config file, via the required `-config` flag
 The format is YAML:
 
 ```yaml
+sitename: parnassus
 baseurl: https://code.example
 repos:
   parnass:
@@ -29,6 +30,13 @@ repos:
     description: "deploy scripts"
 ```
 
+- `sitename` (optional) is the display identity of this socle:
+  it appears in the header, page titles, the entrance masthead, and the footer,
+  and it seeds the accent color.
+  When unset, the identity derives from the `baseurl` host's first label when one is configured,
+  else from the machine hostname --
+  but a published socle usually wants to say its own name, not its machine's.
+
 - `baseurl` (optional) is the canonical outside address of this socle:
   an absolute http(s) URL naming only a host.
   When set, it becomes the displayed clone URL
@@ -118,8 +126,8 @@ its look and its markdown dialect: GFM with callout boxes and server-rendered
 `dot` diagrams, heading anchors, and light/dark following the browser's
 `prefers-color-scheme`.
 
-Each host wears an accent color derived from its hostname
-(the `-theme-seed` flag substitutes any string),
+Each socle wears an accent color derived from its sitename
+(the `-theme-seed` flag recolors without renaming, mostly useful for tests),
 so several socle instances in several tabs stay recognizable at a glance.
 
 Known limits
diff --git a/chrome.go b/chrome.go
index 4617f35..b009eeb 100644
--- a/chrome.go
+++ b/chrome.go
@@ -18,13 +18,13 @@ import (
 
 // pageData is everything the socle page template needs.
 type pageData struct {
-	Title     string
-	ThemeName string
-	Crumbs    []display.Crumb
-	Arc       []arcButton
-	ArcDrop   int  // the deepest button drop, so the arc region reserves room for it (transforms take no layout space)
-	Dawn      bool // true: the full dawn arc (repo home); false: the compact corner arc
-	Content   template.HTML
+	Title    string
+	SiteName string
+	Crumbs   []display.Crumb
+	Arc      []arcButton
+	ArcDrop  int  // the deepest button drop, so the arc region reserves room for it (transforms take no layout space)
+	Dawn     bool // true: the full dawn arc (repo home); false: the compact corner arc
+	Content  template.HTML
 }
 
 // arcButton is one round button on the arc.  Buttons are for moves; prose links stay links.
@@ -80,7 +80,7 @@ var soclePageTmpl = template.Must(template.New("socle").Parse(`<!doctype html>
 <div class="flank left"></div><div class="flank right"></div>
 <div class="sheet">
 <nav class="crumbs">
-{{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}}
+{{range $i, $c := .Crumbs}}{{if $i}} / {{end}}{{if $c.Href}}<a href="{{$c.Href}}">{{$c.Name}}</a>{{else}}{{$c.Name}}{{end}}{{end}}
 </nav>
 {{if .Arc}}<nav class="arc{{if not .Dawn}} corner{{end}}" aria-label="page moves" style="--arc-drop:{{.ArcDrop}}px">
 {{range .Arc}}<a class="v{{.Variant}}" href="{{.Href}}"{{if .Title}} title="{{.Title}}"{{end}} style="--dy:{{.DY}}px">{{.Label}}</a>
@@ -89,17 +89,17 @@ var soclePageTmpl = template.Must(template.New("socle").Parse(`<!doctype html>
 </div>
 <footer class="ground">
 <div class="floor"></div>
-<p class="colophon">{{.ThemeName}} · socle</p>
+<p class="colophon">{{.SiteName}} · made with socle</p>
 </footer>
 </body>
 </html>
 `))
 
 // serveSketch hands out one generated sketch asset.  The bytes are a pure function of
-// the theme seed, so they are safe to cache long; a redeploy with a new seed changes
-// nothing here (same URLs, new bytes), hence long-but-not-immutable.
-func (s *server) serveSketch(w http.ResponseWriter, name string) {
-	b, ok := s.sketch[name]
+// the theme seed, so they are safe to cache long; a redeploy or reload with a new seed
+// changes nothing here (same URLs, new bytes), hence long-but-not-immutable.
+func serveSketch(w http.ResponseWriter, snap *snapshot, name string) {
+	b, ok := snap.face.sketch[name]
 	if !ok {
 		http.Error(w, "no such sketch asset", http.StatusNotFound)
 		return
@@ -497,9 +497,8 @@ func buildStyle(theme display.Theme) []byte {
 	return []byte(sb.String())
 }
 
-// renderPage fills in the site-wide theme and writes the full page.
+// renderPage computes the arc's reserved height and writes the full page.
 func (s *server) renderPage(w http.ResponseWriter, pd pageData) {
-	pd.ThemeName = s.theme.Name
 	for _, b := range pd.Arc {
 		pd.ArcDrop = max(pd.ArcDrop, b.DY)
 	}
diff --git a/config.go b/config.go
index 7422224..0548343 100644
--- a/config.go
+++ b/config.go
@@ -23,6 +23,12 @@ type repoConfig struct {
 
 // config is the top-level schema of the socle config file.
 type config struct {
+	// Sitename is the display identity of this socle: it appears in the header,
+	// page titles, the entrance masthead, the footer, and it seeds the theme color.
+	// Free-form text; optional.  When empty the identity derives from the baseurl's
+	// host label when one is configured, else from the machine hostname --
+	// but a published socle usually wants to say its own name, not its machine's.
+	Sitename string `yaml:"sitename"`
 	// BaseURL is the canonical outside address of this socle, e.g. "https://code.example":
 	// scheme and host only, since socle's URL space is rooted at "/".
 	// When set, it becomes the displayed clone URL and enables the go-get vanity-import
@@ -54,6 +60,7 @@ func loadConfig(path string) (*config, error) {
 		return nil, fmt.Errorf("config %s: %w", path, err)
 	}
 
+	cfg.Sitename = strings.TrimSpace(cfg.Sitename)
 	if cfg.BaseURL != "" {
 		normalized, err := validateBaseURL(cfg.BaseURL)
 		if err != nil {
diff --git a/main.go b/main.go
index 94d6528..3b0383e 100644
--- a/main.go
+++ b/main.go
@@ -7,60 +7,59 @@ import (
 	"flag"
 	"log"
 	"net/http"
-	"os"
 	"time"
-
-	"vitrine/display"
 )
 
 func main() {
 	configPath := flag.String("config", "", "path to the socle config file (required; there is no default location)")
 	listen := flag.String("listen", ":8080", "listen address")
-	themeSeed := flag.String("theme-seed", "", "theme name override (default: first dot-segment of the hostname)")
+	themeSeed := flag.String("theme-seed", "", "theme seed override (default: the sitename; see buildFace for how that resolves)")
 	flag.Parse()
 	if *configPath == "" {
 		log.Fatal("socle: the -config flag is required")
 	}
-	seed := *themeSeed
-	if seed == "" {
-		hostname, err := os.Hostname()
-		if err != nil {
-			log.Fatalf("socle: cannot determine hostname for theming (use -theme-seed to bypass): %v", err)
-		}
-		seed = display.HostLabel(hostname)
-	}
 	cfg, err := loadConfig(*configPath)
 	if err != nil {
 		log.Fatal("socle: ", err)
 	}
+	fc, err := buildFace(cfg, *themeSeed)
+	if err != nil {
+		log.Fatal("socle: ", err)
+	}
 	repos, warnings := openAll(cfg)
 	for _, w := range warnings {
 		log.Print("socle: ", w)
 	}
-	srv := newServer(repos, cfg.BaseURL, display.NewTheme(seed))
+	srv := newServer(repos, cfg.BaseURL, fc)
 
 	// Config changes apply live: reloads are fail-stable
 	// (any problem logs loudly and the old repo set keeps serving),
 	// while startup above stays fail-closed as ever.
 	watchConfig(*configPath, 5*time.Second, func() {
-		reloadConfig(srv, *configPath)
+		reloadConfig(srv, *configPath, *themeSeed)
 	})
 
 	log.Printf("socle: serving %d repos on %s", len(repos), *listen)
 	log.Fatal(http.ListenAndServe(*listen, srv))
 }
 
-// reloadConfig loads the config file and swaps the server's repo set to it.
+// reloadConfig loads the config file and swaps the server's snapshot to it,
+// identity included (a sitename edit lands live, like everything else in the config).
 // Fail-stable, unlike startup: a config that cannot be loaded (parse or validation
 // failure) or that yields not one openable repo is rejected loudly and the previous
 // set keeps serving -- a showcase must not go dark from one typo mid-edit.
 // Per-repo open failures are the same warning-and-skip as at startup.
-func reloadConfig(srv *server, path string) {
+func reloadConfig(srv *server, path, themeSeed string) {
 	cfg, err := loadConfig(path)
 	if err != nil {
 		log.Printf("socle: config reload REJECTED, keeping previous repos: %v", err)
 		return
 	}
+	fc, err := buildFace(cfg, themeSeed)
+	if err != nil {
+		log.Printf("socle: config reload REJECTED, keeping previous repos: %v", err)
+		return
+	}
 	repos, warnings := openAll(cfg)
 	for _, w := range warnings {
 		log.Print("socle: ", w)
@@ -69,6 +68,6 @@ func reloadConfig(srv *server, path string) {
 		log.Printf("socle: config reload REJECTED, keeping previous repos: no configured repo could be opened")
 		return
 	}
-	srv.publish(repos, cfg.BaseURL)
+	srv.publish(repos, cfg.BaseURL, fc)
 	log.Printf("socle: config reloaded: %d repos", len(repos))
 }
diff --git a/pages.go b/pages.go
index 722ba75..5138212 100644
--- a/pages.go
+++ b/pages.go
@@ -21,19 +21,20 @@ import (
 // with the gallery-sketchbook chrome coming from chrome.go and markdown rendering
 // from the display package.
 
-const siteName = "socle"
-
 // logLimit caps the commit log page; history older than this is reachable by
 // following parent shas (and a real pager can come later if it's ever missed).
 const logLimit = 100
 
 // render executes one content-fragment template; a failure is an internal error (500).
-func (s *server) render(w http.ResponseWriter, tmpl *template.Template, data any, pd pageData) {
+// The snapshot supplies the site identity, so every page wears the face of the config
+// it was served under.
+func (s *server) render(w http.ResponseWriter, snap *snapshot, tmpl *template.Template, data any, pd pageData) {
 	var buf bytes.Buffer
 	if err := tmpl.Execute(&buf, data); err != nil {
 		httpError(w, err)
 		return
 	}
+	pd.SiteName = snap.face.siteName
 	pd.Content = template.HTML(buf.String())
 	s.renderPage(w, pd)
 }
@@ -78,8 +79,8 @@ type crumbLink = display.Crumb
 
 // repoCrumbs starts a breadcrumb trail: site, then the repo (its full name as one crumb,
 // slashes and all: intermediate name segments are not pages).
-func repoCrumbs(name string, more ...crumbLink) []display.Crumb {
-	cs := []display.Crumb{{Name: siteName, Href: "/"}, {Name: name, Href: "/" + name + "/"}}
+func repoCrumbs(site, name string, more ...crumbLink) []display.Crumb {
+	cs := []display.Crumb{{Name: site, Href: "/"}, {Name: name, Href: "/" + name + "/"}}
 	cs = append(cs, more...)
 	cs[len(cs)-1].Href = "" // the last crumb is the page being viewed
 	return cs
@@ -110,10 +111,10 @@ type indexRow struct {
 	LangColor   string // its linguist color; the placard dot only shows when one exists
 }
 
-func (s *server) serveIndex(w http.ResponseWriter, r *http.Request, repos map[string]*repo) {
+func (s *server) serveIndex(w http.ResponseWriter, r *http.Request, snap *snapshot) {
 	var rows []indexRow
-	for _, name := range sortedNames(repos) {
-		rp := repos[name]
+	for _, name := range sortedNames(snap.repos) {
+		rp := snap.repos[name]
 		row := indexRow{Name: name, Description: rp.description, Branch: rp.defaultBranch()}
 		// Enrichment is best-effort, matching the caption's existing stance:
 		// the entrance still stands when one repo cannot answer.
@@ -126,9 +127,9 @@ func (s *server) serveIndex(w http.ResponseWriter, r *http.Request, repos map[st
 		}
 		rows = append(rows, row)
 	}
-	s.render(w, indexTmpl, rows, pageData{
-		Title:  siteName,
-		Crumbs: []display.Crumb{{Name: siteName}},
+	s.render(w, snap, indexTmpl, rows, pageData{
+		Title:  snap.face.siteName,
+		Crumbs: []display.Crumb{{Name: snap.face.siteName}},
 	})
 }
 
@@ -209,11 +210,11 @@ func langSegments(stats []langStat) []langSeg {
 	return segs
 }
 
-func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo, baseURL string) {
+func (s *server) serveHome(w http.ResponseWriter, r *http.Request, snap *snapshot, rp *repo) {
 	// The clone URL: the configured canonical address when there is one,
 	// else derived from the request (right whenever no rewriting proxy intervenes).
-	cloneURL := baseURL + "/" + rp.name
-	if baseURL == "" {
+	cloneURL := snap.baseURL + "/" + rp.name
+	if snap.baseURL == "" {
 		scheme := "http"
 		if r.TLS != nil {
 			scheme = "https"
@@ -261,7 +262,7 @@ func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo, bas
 	}
 	pd := pageData{
 		Title:  rp.name,
-		Crumbs: repoCrumbs(rp.name),
+		Crumbs: repoCrumbs(snap.face.siteName, rp.name),
 	}
 	if !d.Empty {
 		pd.Dawn = true
@@ -272,7 +273,7 @@ func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo, bas
 			{Label: "clone", Title: "the clone command", Href: "#clone"},
 		})
 	}
-	s.render(w, homeTmpl, d, pd)
+	s.render(w, snap, homeTmpl, d, pd)
 }
 
 // readmeInfo is what the home plaque says about the README it accompanies.
@@ -348,7 +349,7 @@ type treeData struct {
 	Rows  []treeRow
 }
 
-func (s *server) serveTreeListing(w http.ResponseWriter, rp *repo, ref, sha, pth string) {
+func (s *server) serveTreeListing(w http.ResponseWriter, snap *snapshot, rp *repo, ref, sha, pth string) {
 	entries, err := rp.lsTree(sha, pth)
 	if err != nil {
 		httpError(w, err)
@@ -392,15 +393,15 @@ func (s *server) serveTreeListing(w http.ResponseWriter, rp *repo, ref, sha, pth
 		arcButton{Label: "log", Title: "commit log of " + shortSha(ref), Href: "/" + rp.name + "/log/" + ref},
 		arcButton{Label: "home", Title: "repo home", Href: "/" + rp.name + "/"},
 	)
-	s.render(w, treeTmpl, d, pageData{
+	s.render(w, snap, treeTmpl, d, pageData{
 		Title:  pageTitle(rp.name, ref, pth),
-		Crumbs: treeCrumbs(rp.name, ref, pth),
+		Crumbs: treeCrumbs(snap.face.siteName, rp.name, ref, pth),
 		Arc:    cornerArc(btns),
 	})
 }
 
 // treeCrumbs: site / repo / ref / path segments, each linking to its listing.
-func treeCrumbs(name, ref, pth string, more ...crumbLink) []display.Crumb {
+func treeCrumbs(site, name, ref, pth string, more ...crumbLink) []display.Crumb {
 	cs := []crumbLink{{Name: ref, Href: treeHref(name, ref, "") + "/"}}
 	if pth != "" {
 		segs := strings.Split(pth, "/")
@@ -408,7 +409,7 @@ func treeCrumbs(name, ref, pth string, more ...crumbLink) []display.Crumb {
 			cs = append(cs, crumbLink{Name: seg, Href: treeHref(name, ref, strings.Join(segs[:i+1], "/")) + "/"})
 		}
 	}
-	return repoCrumbs(name, append(cs, more...)...)
+	return repoCrumbs(site, name, append(cs, more...)...)
 }
 
 // humanSize renders a byte count the way a listing wants it: exact while it is small,
@@ -490,12 +491,12 @@ type plaqueData struct {
 
 // serveFileView shows a blob as a page: markdown rendered, text preformatted, images inlined,
 // and anything binary or oversized as a link to the raw view.
-func (s *server) serveFileView(w http.ResponseWriter, rp *repo, ref, sha, pth string) {
+func (s *server) serveFileView(w http.ResponseWriter, snap *snapshot, rp *repo, ref, sha, pth string) {
 	name := path.Base(pth)
 	raw := rawHref(rp.name, ref, pth)
 	pd := pageData{
 		Title:  pageTitle(rp.name, ref, pth),
-		Crumbs: treeCrumbs(rp.name, ref, parentDir(pth), crumbLink{Name: name}),
+		Crumbs: treeCrumbs(snap.face.siteName, rp.name, ref, parentDir(pth), crumbLink{Name: name}),
 		Arc: cornerArc([]arcButton{
 			{Label: "raw", Title: "the raw bytes", Href: raw},
 			{Label: "up", Title: "containing directory", Href: treeHref(rp.name, ref, parentDir(pth)) + "/"},
@@ -510,14 +511,14 @@ func (s *server) serveFileView(w http.ResponseWriter, rp *repo, ref, sha, pth st
 	}
 	plq := plaqueData{Path: pth, Size: humanSize(size), ShortSha: shortSha(sha)}
 	if isImagePath(pth) {
-		s.render(w, imageTmpl, struct {
+		s.render(w, snap, imageTmpl, struct {
 			Name, RawHref string
 			Plaque        plaqueData
 		}{name, raw, plq}, pd)
 		return
 	}
 	rawLink := func(kind string) {
-		s.render(w, rawLinkTmpl, struct {
+		s.render(w, snap, rawLinkTmpl, struct {
 			Name, Kind, RawHref string
 			Size                int64
 		}{name, kind, raw, size}, pd)
@@ -541,7 +542,7 @@ func (s *server) serveFileView(w http.ResponseWriter, rp *repo, ref, sha, pth st
 			httpError(w, err)
 			return
 		}
-		s.render(w, fileTmpl, struct {
+		s.render(w, snap, fileTmpl, struct {
 			Name   string
 			Body   template.HTML
 			Plaque plaqueData
@@ -553,7 +554,7 @@ func (s *server) serveFileView(w http.ResponseWriter, rp *repo, ref, sha, pth st
 		httpError(w, err)
 		return
 	}
-	s.render(w, fileTmpl, struct {
+	s.render(w, snap, fileTmpl, struct {
 		Name   string
 		Body   template.HTML
 		Plaque plaqueData
@@ -604,7 +605,7 @@ type logData struct {
 
 // renderLog writes the log page.  Commit shas link to the tree at that commit:
 // commit-diff pages are a later stretch goal, and browsing the snapshot is useful today.
-func (s *server) renderLog(w http.ResponseWriter, rp *repo, ref string, cs []commitInfo) {
+func (s *server) renderLog(w http.ResponseWriter, snap *snapshot, rp *repo, ref string, cs []commitInfo) {
 	d := logData{Title: shortSha(ref), Capped: len(cs) == logLimit, Limit: logLimit}
 	for _, c := range cs {
 		d.Rows = append(d.Rows, logRow{
@@ -615,9 +616,9 @@ func (s *server) renderLog(w http.ResponseWriter, rp *repo, ref string, cs []com
 			Subject: c.subject,
 		})
 	}
-	s.render(w, logTmpl, d, pageData{
+	s.render(w, snap, logTmpl, d, pageData{
 		Title:  rp.name + " log @ " + shortSha(ref),
-		Crumbs: repoCrumbs(rp.name, crumbLink{Name: "log"}, crumbLink{Name: shortSha(ref)}),
+		Crumbs: repoCrumbs(snap.face.siteName, rp.name, crumbLink{Name: "log"}, crumbLink{Name: shortSha(ref)}),
 		Arc: cornerArc([]arcButton{
 			{Label: "files", Title: "browse the tree at " + shortSha(ref), Href: treeHref(rp.name, ref, "") + "/"},
 			{Label: "home", Title: "repo home", Href: "/" + rp.name + "/"},
@@ -650,7 +651,7 @@ type commitData struct {
 }
 
 // renderCommit writes the commit page: subject, metadata, the rest of the message, the diff.
-func (s *server) renderCommit(w http.ResponseWriter, rp *repo, c commitDetail, diff []byte) {
+func (s *server) renderCommit(w http.ResponseWriter, snap *snapshot, rp *repo, c commitDetail, diff []byte) {
 	subject, rest, _ := strings.Cut(c.message, "\n")
 	d := commitData{
 		Subject:  subject,
@@ -664,9 +665,9 @@ func (s *server) renderCommit(w http.ResponseWriter, rp *repo, c commitDetail, d
 		d.Parents = append(d.Parents, refLink{shortSha(p), "/" + rp.name + "/commit/" + p})
 	}
 	d.Diff, d.Truncated = diffHTML(diff)
-	s.render(w, commitTmpl, d, pageData{
+	s.render(w, snap, commitTmpl, d, pageData{
 		Title:  rp.name + " commit " + shortSha(c.hash),
-		Crumbs: repoCrumbs(rp.name, crumbLink{Name: "commit"}, crumbLink{Name: shortSha(c.hash)}),
+		Crumbs: repoCrumbs(snap.face.siteName, rp.name, crumbLink{Name: "commit"}, crumbLink{Name: shortSha(c.hash)}),
 		Arc: cornerArc([]arcButton{
 			{Label: "files", Title: "browse the tree at this commit", Href: treeHref(rp.name, c.hash, "") + "/"},
 			{Label: "log", Title: "the log from this commit", Href: "/" + rp.name + "/log/" + c.hash},
diff --git a/serve.go b/serve.go
index 5a23982..a6c0da1 100644
--- a/serve.go
+++ b/serve.go
@@ -7,6 +7,7 @@ import (
 	"log"
 	"net/http"
 	"net/url"
+	"os"
 	"strings"
 	"sync/atomic"
 
@@ -14,12 +15,59 @@ import (
 )
 
 // snapshot is the server's immutable view of one loaded config:
-// the opened repo set, plus the canonical base URL (empty when unconfigured;
-// see config.BaseURL for what each state means).
-// The two travel together so a reload can never pair old repos with a new address.
+// the opened repo set, the canonical base URL (empty when unconfigured;
+// see config.BaseURL for what each state means), and the face.
+// All of it travels together so a reload can never pair old repos with a new address
+// or new identity.
 type snapshot struct {
 	repos   map[string]*repo
 	baseURL string
+	face    face
+}
+
+// face is the config-derived visual identity: the displayed sitename, the theme it seeds,
+// and the sketch assets and stylesheet drawn with that seed.
+// It lives in the snapshot, not the server, because the sitename comes from the config
+// and the config reloads live; rebuilding it is cheap and deterministic
+// (an unchanged seed regenerates byte-identical assets).
+type face struct {
+	siteName string
+	theme    display.Theme
+	sketch   map[string][]byte // the generated sketch SVGs, by filename; see sketch.go
+	style    []byte            // the assembled /-/style.css body; see buildStyle
+}
+
+// buildFace resolves the display identity from a config and draws its assets.
+// seedOverride (the -theme-seed flag) decouples the color from the name when set;
+// it is a process-lifetime override, so it applies across reloads too.
+func buildFace(cfg *config, seedOverride string) (face, error) {
+	name := cfg.Sitename
+	if name == "" && cfg.BaseURL != "" {
+		u, err := url.Parse(cfg.BaseURL)
+		if err != nil {
+			// Unreachable in practice: validateBaseURL already parsed it.
+			return face{}, fmt.Errorf("deriving sitename from baseurl: %w", err)
+		}
+		name = display.HostLabel(u.Host)
+	}
+	if name == "" {
+		hostname, err := os.Hostname()
+		if err != nil {
+			return face{}, fmt.Errorf("cannot determine a sitename: no sitename or baseurl configured, and reading the hostname failed: %w", err)
+		}
+		name = display.HostLabel(hostname)
+	}
+	seed := seedOverride
+	if seed == "" {
+		seed = name
+	}
+	th := display.NewTheme(seed)
+	return face{
+		siteName: name,
+		theme:    th,
+		sketch:   sketchAssets(seed),
+		style:    buildStyle(th),
+	}, nil
 }
 
 // server serves the opened repos over HTTP: browse pages, raw blobs, and smart-HTTP fetch.
@@ -28,21 +76,15 @@ type snapshot struct {
 // (at the top of ServeHTTP) and works with that immutable snapshot for its whole duration.
 // A published snapshot is never mutated.
 type server struct {
-	snap   atomic.Pointer[snapshot]
-	theme  display.Theme     // fixed for the process lifetime; see display/theme.go
-	sketch map[string][]byte // the generated sketch SVGs, by filename; see sketch.go
-	style  []byte            // the assembled /-/style.css body; see buildStyle
-	langs  *langCache        // language composition per (repo, sha); see lang.go
+	snap  atomic.Pointer[snapshot]
+	langs *langCache // language composition per (repo, sha); see lang.go
 }
 
-func newServer(repos map[string]*repo, baseURL string, th display.Theme) *server {
+func newServer(repos map[string]*repo, baseURL string, fc face) *server {
 	s := &server{
-		theme:  th,
-		sketch: sketchAssets(th.Name),
-		style:  buildStyle(th),
-		langs:  newLangCache(langCacheCap),
+		langs: newLangCache(langCacheCap),
 	}
-	s.publish(repos, baseURL)
+	s.publish(repos, baseURL, fc)
 	return s
 }
 
@@ -52,8 +94,8 @@ func newServer(repos map[string]*repo, baseURL string, th display.Theme) *server
 // The language-stats cache deliberately survives the swap: it is keyed (repo-name, commit-sha)
 // and a sha content-addresses the commit's tree, so an entry can be orphaned by a reload
 // but never wrong, and the cache's cap already bounds orphans.
-func (s *server) publish(repos map[string]*repo, baseURL string) {
-	s.snap.Store(&snapshot{repos: repos, baseURL: baseURL})
+func (s *server) publish(repos map[string]*repo, baseURL string, fc face) {
+	s.snap.Store(&snapshot{repos: repos, baseURL: baseURL, face: fc})
 }
 
 // ServeHTTP dispatches: the two server-wide paths first, then longest-prefix repo matching
@@ -79,14 +121,14 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	switch {
 	case p == "":
-		s.serveIndex(w, r, repos)
+		s.serveIndex(w, r, snap)
 		return
 	case p == "-/style.css":
 		w.Header().Set("Content-Type", "text/css; charset=utf-8")
-		w.Write(s.style)
+		w.Write(snap.face.style)
 		return
 	case strings.HasPrefix(p, "-/sketch/"):
-		s.serveSketch(w, strings.TrimPrefix(p, "-/sketch/"))
+		serveSketch(w, snap, strings.TrimPrefix(p, "-/sketch/"))
 		return
 	}
 
@@ -112,7 +154,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			http.Redirect(w, r, r.URL.EscapedPath()+"/", http.StatusMovedPermanently)
 			return
 		}
-		s.serveHome(w, r, rp, snap.baseURL)
+		s.serveHome(w, r, snap, rp)
 		return
 	}
 
@@ -123,13 +165,13 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	case verb == "git-upload-pack" && arg == "":
 		s.serveUploadPack(w, r, rp)
 	case verb == "tree":
-		s.serveTree(w, r, rp, arg)
+		s.serveTree(w, r, snap, rp, arg)
 	case verb == "raw":
 		s.serveRawBlob(w, r, rp, arg)
 	case verb == "log":
-		s.serveLog(w, r, rp, arg)
+		s.serveLog(w, r, snap, rp, arg)
 	case verb == "commit":
-		s.serveCommit(w, r, rp, arg)
+		s.serveCommit(w, r, snap, rp, arg)
 	default:
 		s.redirectIntoTree(w, r, rp, rest)
 	}
@@ -187,7 +229,7 @@ func splitRefPath(arg string) (ref, path string) {
 }
 
 // serveTree handles /<name>/tree/<ref>[/<path>]: a listing for trees, a file view for blobs.
-func (s *server) serveTree(w http.ResponseWriter, r *http.Request, rp *repo, arg string) {
+func (s *server) serveTree(w http.ResponseWriter, r *http.Request, snap *snapshot, rp *repo, arg string) {
 	ref, path := splitRefPath(arg)
 	sha, err := rp.resolveCommit(ref)
 	if err != nil {
@@ -202,9 +244,9 @@ func (s *server) serveTree(w http.ResponseWriter, r *http.Request, rp *repo, arg
 	setCacheHeader(w, ref, sha)
 	switch typ {
 	case "tree":
-		s.serveTreeListing(w, rp, ref, sha, path)
+		s.serveTreeListing(w, snap, rp, ref, sha, path)
 	case "blob":
-		s.serveFileView(w, rp, ref, sha, path)
+		s.serveFileView(w, snap, rp, ref, sha, path)
 	default:
 		// A submodule pointer: there is nothing of it in this repo to show.
 		http.Error(w, fmt.Sprintf("path %q is a submodule pointer, not browsable here", path), http.StatusNotFound)
@@ -250,7 +292,7 @@ func (s *server) serveRawBlob(w http.ResponseWriter, r *http.Request, rp *repo,
 }
 
 // serveLog handles /<name>/log/<ref>.
-func (s *server) serveLog(w http.ResponseWriter, r *http.Request, rp *repo, arg string) {
+func (s *server) serveLog(w http.ResponseWriter, r *http.Request, snap *snapshot, rp *repo, arg string) {
 	ref := strings.TrimSuffix(arg, "/")
 	sha, err := rp.resolveCommit(ref)
 	if err != nil {
@@ -263,13 +305,13 @@ func (s *server) serveLog(w http.ResponseWriter, r *http.Request, rp *repo, arg
 		return
 	}
 	setCacheHeader(w, ref, sha)
-	s.renderLog(w, rp, ref, cs)
+	s.renderLog(w, snap, rp, ref, cs)
 }
 
 // serveCommit handles /<name>/commit/<sha>: the commit's metadata, message, and diff.
 // Any ref is accepted where <sha> is written, but links always use full shas,
 // so the pages that matter get the immutable cache header.
-func (s *server) serveCommit(w http.ResponseWriter, r *http.Request, rp *repo, arg string) {
+func (s *server) serveCommit(w http.ResponseWriter, r *http.Request, snap *snapshot, rp *repo, arg string) {
 	ref := strings.TrimSuffix(arg, "/")
 	sha, err := rp.resolveCommit(ref)
 	if err != nil {
@@ -287,7 +329,7 @@ func (s *server) serveCommit(w http.ResponseWriter, r *http.Request, rp *repo, a
 		return
 	}
 	setCacheHeader(w, ref, sha)
-	s.renderCommit(w, rp, c, diff)
+	s.renderCommit(w, snap, rp, c, diff)
 }
 
 // redirectIntoTree sends /<name>/<path> (any path that isn't a verb) into the tree at the
diff --git a/serve_test.go b/serve_test.go
index ea7c364..4f8f2d8 100644
--- a/serve_test.go
+++ b/serve_test.go
@@ -14,10 +14,21 @@ import (
 )
 
 // fixtureServer builds a server over the standard hermetic fixture repo,
-// with a pinned theme seed so output stays deterministic.
+// with a pinned identity so output stays deterministic.
 func fixtureServer(t *testing.T) *server {
 	t.Helper()
-	return newServer(map[string]*repo{"fixturama": fixtureRepo(t)}, "", display.NewTheme("test"))
+	return newServer(map[string]*repo{"fixturama": fixtureRepo(t)}, "", testFace(t))
+}
+
+// testFace is the pinned identity every test server wears: sitename (and thus seed) "test",
+// so page chrome and sketch assets stay deterministic regardless of the machine.
+func testFace(t *testing.T) face {
+	t.Helper()
+	fc, err := buildFace(&config{Sitename: "test"}, "")
+	if err != nil {
+		t.Fatalf("building the test face: %v", err)
+	}
+	return fc
 }
 
 // do runs one request through the handler and returns the recorded response.
@@ -208,7 +219,7 @@ func TestFileViewModes(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	s := newServer(map[string]*repo{"media": rp}, "", display.NewTheme("test"))
+	s := newServer(map[string]*repo{"media": rp}, "", testFace(t))
 	for _, c := range []struct {
 		target string
 		want   string
@@ -252,7 +263,7 @@ func TestCommitPageShapes(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	s := newServer(map[string]*repo{"merger": rp}, "", display.NewTheme("test"))
+	s := newServer(map[string]*repo{"merger": rp}, "", testFace(t))
 	rec := do(s, "/merger/commit/main", nil, nil)
 	if rec.Code != 200 {
 		t.Fatalf("status = %d", rec.Code)
@@ -282,7 +293,7 @@ func TestGoGetMeta(t *testing.T) {
 	wantImport := `<meta name="go-import" content="code.example/fixturama git https://code.example/fixturama">`
 	wantSource := `<meta name="go-source" content="code.example/fixturama https://code.example/fixturama/ https://code.example/fixturama/tree/HEAD{/dir} https://code.example/fixturama/tree/HEAD{/dir}/{file}">`
 
-	s := newServer(map[string]*repo{"fixturama": fixtureRepo(t)}, "https://code.example", display.NewTheme("test"))
+	s := newServer(map[string]*repo{"fixturama": fixtureRepo(t)}, "https://code.example", testFace(t))
 	for _, target := range []string{
 		"/fixturama?go-get=1",           // the slashless home: no redirect detour (it would drop the query)
 		"/fixturama/?go-get=1",          // the home itself
@@ -319,3 +330,34 @@ func TestGoGetMeta(t *testing.T) {
 		t.Errorf("no baseurl: home page must not carry a go-import tag")
 	}
 }
+
+// TestFaceIdentityResolution pins how the displayed identity and the theme seed resolve:
+// an explicit sitename wins, a baseurl's host label is the fallback,
+// and -theme-seed decouples the color without touching the name.
+func TestFaceIdentityResolution(t *testing.T) {
+	t.Parallel()
+	for _, c := range []struct {
+		name      string
+		cfg       config
+		override  string
+		wantName  string
+		wantTheme string
+	}{
+		{name: "sitename wins over baseurl", cfg: config{Sitename: "parnassus", BaseURL: "https://code.example"}, wantName: "parnassus", wantTheme: "parnassus"},
+		{name: "baseurl label when no sitename", cfg: config{BaseURL: "https://code.example"}, wantName: "code", wantTheme: "code"},
+		{name: "seed override recolors but does not rename", cfg: config{Sitename: "parnassus"}, override: "zebra", wantName: "parnassus", wantTheme: "zebra"},
+	} {
+		t.Run(c.name, func(t *testing.T) {
+			fc, err := buildFace(&c.cfg, c.override)
+			if err != nil {
+				t.Fatal(err)
+			}
+			if fc.siteName != c.wantName {
+				t.Errorf("siteName = %q, want %q", fc.siteName, c.wantName)
+			}
+			if fc.theme.Name != c.wantTheme {
+				t.Errorf("theme seed = %q, want %q", fc.theme.Name, c.wantTheme)
+			}
+		})
+	}
+}
diff --git a/testdata/pages.md b/testdata/pages.md
index 755f0c5..cfd6cd5 100644
--- a/testdata/pages.md
+++ b/testdata/pages.md
@@ -28,14 +28,14 @@ Content-Type: text/html; charset=utf-8
 <head>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
-<title>socle</title>
+<title>test</title>
 <link rel="stylesheet" href="/-/style.css">
 </head>
 <body>
 <div class="flank left"></div><div class="flank right"></div>
 <div class="sheet">
 <nav class="crumbs">
-socle<span class="theme">@test</span>
+test
 </nav>
 <main><article><h1>repositories</h1>
 <ul class="entrance">
@@ -50,7 +50,7 @@ socle<span class="theme">@test</span>
 </div>
 <footer class="ground">
 <div class="floor"></div>
-<p class="colophon">test · socle</p>
+<p class="colophon">test · made with socle</p>
 </footer>
 </body>
 </html>
@@ -84,7 +84,7 @@ Content-Type: text/html; charset=utf-8
 <div class="flank left"></div><div class="flank right"></div>
 <div class="sheet">
 <nav class="crumbs">
-<a href="/">socle</a><span class="theme">@test</span> / fixturama
+<a href="/">test</a> / fixturama
 </nav>
 <nav class="arc" aria-label="page moves" style="--arc-drop:67px">
 <a class="va" href="/fixturama/tree/main/" title="browse the tree at main" style="--dy:67px">files</a>
@@ -108,7 +108,7 @@ Content-Type: text/html; charset=utf-8
 </div>
 <footer class="ground">
 <div class="floor"></div>
-<p class="colophon">test · socle</p>
+<p class="colophon">test · made with socle</p>
 </footer>
 </body>
 </html>
@@ -142,7 +142,7 @@ Content-Type: text/html; charset=utf-8
 <div class="flank left"></div><div class="flank right"></div>
 <div class="sheet">
 <nav class="crumbs">
-<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / main
+<a href="/">test</a> / <a href="/fixturama/">fixturama</a> / main
 </nav>
 <nav class="arc corner" aria-label="page moves" style="--arc-drop:18px">
 <a class="va" href="/fixturama/log/main" title="commit log of main" style="--dy:18px">log</a>
@@ -162,7 +162,7 @@ Content-Type: text/html; charset=utf-8
 </div>
 <footer class="ground">
 <div class="floor"></div>
-<p class="colophon">test · socle</p>
+<p class="colophon">test · made with socle</p>
 </footer>
 </body>
 </html>
@@ -190,7 +190,7 @@ Content-Type: text/html; charset=utf-8
 <div class="flank left"></div><div class="flank right"></div>
 <div class="sheet">
 <nav class="crumbs">
-<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / <a href="/fixturama/tree/main/">main</a> / docs
+<a href="/">test</a> / <a href="/fixturama/">fixturama</a> / <a href="/fixturama/tree/main/">main</a> / docs
 </nav>
 <nav class="arc corner" aria-label="page moves" style="--arc-drop:51px">
 <a class="va" href="/fixturama/tree/main/" title="parent directory" style="--dy:51px">up</a>
@@ -209,7 +209,7 @@ Content-Type: text/html; charset=utf-8
 </div>
 <footer class="ground">
 <div class="floor"></div>
-<p class="colophon">test · socle</p>
+<p class="colophon">test · made with socle</p>
 </footer>
 </body>
 </html>
@@ -244,7 +244,7 @@ Content-Type: text/html; charset=utf-8
 <div class="flank left"></div><div class="flank right"></div>
 <div class="sheet">
 <nav class="crumbs">
-<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / <a href="/fixturama/tree/main/">main</a> / README.md
+<a href="/">test</a> / <a href="/fixturama/">fixturama</a> / <a href="/fixturama/tree/main/">main</a> / README.md
 </nav>
 <nav class="arc corner" aria-label="page moves" style="--arc-drop:109px">
 <a class="va" href="/fixturama/raw/main/README.md" title="the raw bytes" style="--dy:109px">raw</a>
@@ -261,7 +261,7 @@ Content-Type: text/html; charset=utf-8
 </div>
 <footer class="ground">
 <div class="floor"></div>
-<p class="colophon">test · socle</p>
+<p class="colophon">test · made with socle</p>
 </footer>
 </body>
 </html>
@@ -289,7 +289,7 @@ Content-Type: text/html; charset=utf-8
 <div class="flank left"></div><div class="flank right"></div>
 <div class="sheet">
 <nav class="crumbs">
-<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / <a href="/fixturama/tree/main/">main</a> / <a href="/fixturama/tree/main/docs/">docs</a> / with space.txt
+<a href="/">test</a> / <a href="/fixturama/">fixturama</a> / <a href="/fixturama/tree/main/">main</a> / <a href="/fixturama/tree/main/docs/">docs</a> / with space.txt
 </nav>
 <nav class="arc corner" aria-label="page moves" style="--arc-drop:109px">
 <a class="va" href="/fixturama/raw/main/docs/with%20space.txt" title="the raw bytes" style="--dy:109px">raw</a>
@@ -305,7 +305,7 @@ Content-Type: text/html; charset=utf-8
 </div>
 <footer class="ground">
 <div class="floor"></div>
-<p class="colophon">test · socle</p>
+<p class="colophon">test · made with socle</p>
 </footer>
 </body>
 </html>
@@ -334,7 +334,7 @@ Content-Type: text/html; charset=utf-8
 <div class="flank left"></div><div class="flank right"></div>
 <div class="sheet">
 <nav class="crumbs">
-<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / <a href="/fixturama/tree/ced91a8519b59966124301e6383532f24223528b/">ced91a8519b59966124301e6383532f24223528b</a> / README.md
+<a href="/">test</a> / <a href="/fixturama/">fixturama</a> / <a href="/fixturama/tree/ced91a8519b59966124301e6383532f24223528b/">ced91a8519b59966124301e6383532f24223528b</a> / README.md
 </nav>
 <nav class="arc corner" aria-label="page moves" style="--arc-drop:109px">
 <a class="va" href="/fixturama/raw/ced91a8519b59966124301e6383532f24223528b/README.md" title="the raw bytes" style="--dy:109px">raw</a>
@@ -351,7 +351,7 @@ Content-Type: text/html; charset=utf-8
 </div>
 <footer class="ground">
 <div class="floor"></div>
-<p class="colophon">test · socle</p>
+<p class="colophon">test · made with socle</p>
 </footer>
 </body>
 </html>
@@ -383,7 +383,7 @@ Content-Type: text/html; charset=utf-8
 <div class="flank left"></div><div class="flank right"></div>
 <div class="sheet">
 <nav class="crumbs">
-<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / log / main
+<a href="/">test</a> / <a href="/fixturama/">fixturama</a> / log / main
 </nav>
 <nav class="arc corner" aria-label="page moves" style="--arc-drop:18px">
 <a class="va" href="/fixturama/tree/main/" title="browse the tree at main" style="--dy:18px">files</a>
@@ -405,7 +405,7 @@ Content-Type: text/html; charset=utf-8
 </div>
 <footer class="ground">
 <div class="floor"></div>
-<p class="colophon">test · socle</p>
+<p class="colophon">test · made with socle</p>
 </footer>
 </body>
 </html>
@@ -440,7 +440,7 @@ Content-Type: text/html; charset=utf-8
 <div class="flank left"></div><div class="flank right"></div>
 <div class="sheet">
 <nav class="crumbs">
-<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / commit / ced91a8519
+<a href="/">test</a> / <a href="/fixturama/">fixturama</a> / commit / ced91a8519
 </nav>
 <nav class="arc corner" aria-label="page moves" style="--arc-drop:51px">
 <a class="va" href="/fixturama/tree/ced91a8519b59966124301e6383532f24223528b/" title="browse the tree at this commit" style="--dy:51px">files</a>
@@ -521,7 +521,7 @@ Ada Fixture, 2026-01-02 03:04:05 UTC</p>
 </div>
 <footer class="ground">
 <div class="floor"></div>
-<p class="colophon">test · socle</p>
+<p class="colophon">test · made with socle</p>
 </footer>
 </body>
 </html>
@@ -552,7 +552,7 @@ Content-Type: text/html; charset=utf-8
 <div class="flank left"></div><div class="flank right"></div>
 <div class="sheet">
 <nav class="crumbs">
-<a href="/">socle</a><span class="theme">@test</span> / <a href="/fixturama/">fixturama</a> / commit / 64ac67a35d
+<a href="/">test</a> / <a href="/fixturama/">fixturama</a> / commit / 64ac67a35d
 </nav>
 <nav class="arc corner" aria-label="page moves" style="--arc-drop:51px">
 <a class="va" href="/fixturama/tree/64ac67a35d36079e167cb8f929fd57f859c8b5d1/" title="browse the tree at this commit" style="--dy:51px">files</a>
@@ -576,7 +576,7 @@ Ada Fixture, 2026-01-02 03:04:05 UTC</p>
 </div>
 <footer class="ground">
 <div class="floor"></div>
-<p class="colophon">test · socle</p>
+<p class="colophon">test · made with socle</p>
 </footer>
 </body>
 </html>
diff --git a/watch_test.go b/watch_test.go
index 58b9557..6e56861 100644
--- a/watch_test.go
+++ b/watch_test.go
@@ -13,8 +13,6 @@ import (
 	"sync/atomic"
 	"testing"
 	"time"
-
-	"vitrine/display"
 )
 
 // watchFixture is a server plus a watched config file in a temp dir,
@@ -51,9 +49,9 @@ func newWatchFixture(t *testing.T, pollInterval time.Duration) *watchFixture {
 	if len(warnings) > 0 {
 		t.Fatalf("fixture repos should all open: %v", warnings)
 	}
-	fx.srv = newServer(repos, cfg.BaseURL, display.NewTheme("test"))
+	fx.srv = newServer(repos, cfg.BaseURL, testFace(t))
 	watchConfig(fx.cfgPath, pollInterval, func() {
-		reloadConfig(fx.srv, fx.cfgPath)
+		reloadConfig(fx.srv, fx.cfgPath, "test")
 		fx.checked.Add(1)
 	})
 	return fx