vitrine

extract rendering into importable display subpackage

commit c714b1582887a4bc481e6248c292a032b93c4ddd · parent b0ad0cdd90 · browse this tree
Ximianth, 2026-10-26 19:28:48 UTC

Pure code motion plus the minimal export renames: markdown conversion
(callouts, diagrams, heading anchors), the page chrome template, the
stylesheet, and hostname theming now live in vitrine/display, with no
HTTP or filesystem coupling.  The server keeps thin wrappers
(serveRendered, renderPage) in render.go.

Crumbs gained a site-name parameter instead of hardcoding "vitrine",
since the package is now meant for sibling tools to import.

Server-coupled tests that had lived in slug_test.go and theme_test.go
move to render_test.go in the main package; pure tests move with their
subjects.  Behavior is identical; all tests pass unchanged in substance.
diff --git a/callout.go b/display/callout.go
similarity index 99%
rename from callout.go
rename to display/callout.go
index 54bd329..f844ef2 100644
--- a/callout.go
+++ b/display/callout.go
@@ -1,4 +1,4 @@
-package main
+package display
 
 import (
 	"bytes"
diff --git a/callout_test.go b/display/callout_test.go
similarity index 99%
rename from callout_test.go
rename to display/callout_test.go
index 69f3efd..54965da 100644
--- a/callout_test.go
+++ b/display/callout_test.go
@@ -1,4 +1,4 @@
-package main
+package display
 
 import (
 	"bytes"
diff --git a/diagram.go b/display/diagram.go
similarity index 99%
rename from diagram.go
rename to display/diagram.go
index fde923f..01ccd69 100644
--- a/diagram.go
+++ b/display/diagram.go
@@ -1,4 +1,4 @@
-package main
+package display
 
 import (
 	"bytes"
diff --git a/diagram_test.go b/display/diagram_test.go
similarity index 99%
rename from diagram_test.go
rename to display/diagram_test.go
index a6dfd93..1b670b2 100644
--- a/diagram_test.go
+++ b/display/diagram_test.go
@@ -1,4 +1,4 @@
-package main
+package display
 
 import (
 	"bytes"
diff --git a/display/display.go b/display/display.go
new file mode 100644
index 0000000..407bdd8
--- /dev/null
+++ b/display/display.go
@@ -0,0 +1,116 @@
+// Package display is vitrine's rendering heart, shared with sibling tools:
+// markdown-to-HTML conversion (with callouts, diagrams, and heading anchors),
+// the page chrome template, and hostname-derived theming.
+// It is pure presentation: no HTTP, no filesystem access.
+package display
+
+import (
+	"bytes"
+	_ "embed"
+	"html/template"
+	"io"
+	"strings"
+
+	"github.com/yuin/goldmark"
+	gmext "github.com/yuin/goldmark/extension"
+	"github.com/yuin/goldmark/parser"
+	gmutil "github.com/yuin/goldmark/util"
+)
+
+// MaxRenderSize caps how much callers should read into memory for rendered views.
+// Bigger files still work fine via raw views, which stream.
+const MaxRenderSize = 4 << 20
+
+var markdown = goldmark.New(
+	goldmark.WithExtensions(gmext.GFM, callouts{}, diagrams{}),
+	goldmark.WithParserOptions(
+		parser.WithAutoHeadingID(),
+		parser.WithASTTransformers(gmutil.Prioritized(headingAnchors{}, 100)),
+	),
+)
+
+// Markdown converts markdown source to HTML.
+func Markdown(src []byte) (template.HTML, error) {
+	var buf bytes.Buffer
+	// A fresh IDs registry per document keeps duplicate-heading suffixes deterministic.
+	ctx := parser.NewContext(parser.WithIDs(newSlugIDs()))
+	if err := markdown.Convert(src, &buf, parser.WithContext(ctx)); err != nil {
+		return "", err
+	}
+	return template.HTML(buf.String()), nil
+}
+
+//go:embed style.css
+var StyleCSS []byte
+
+type DirEntry struct {
+	Name string
+	Href string
+}
+
+type Crumb struct {
+	Name string
+	Href string
+}
+
+type PageData struct {
+	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
+// (no leading slash); rel of "." means the mount root itself.
+func JoinURL(mount, rel string) string {
+	if rel == "." {
+		return mount
+	}
+	return mount + "/" + rel
+}
+
+// Crumbs builds a breadcrumb trail from a URL path (no leading slash):
+// home (shown as the site name), then each path segment.
+// The last segment gets no href; it's the page being viewed.
+func Crumbs(site, urlpath string) []Crumb {
+	cs := []Crumb{{Name: site, Href: "/"}}
+	if urlpath == "" {
+		cs[0].Href = ""
+		return cs
+	}
+	href := "/"
+	for _, seg := range strings.Split(urlpath, "/") {
+		href += seg + "/"
+		cs = append(cs, Crumb{Name: seg, Href: href})
+	}
+	cs[len(cs)-1].Href = ""
+	return cs
+}
+
+var pageTmpl = template.Must(template.New("page").Parse(`<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{.Title}}</title>
+<link rel="stylesheet" href="/-/style.css">
+</head>
+<body>
+<nav>
+{{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}}
+{{if .Content}}<article>{{.Content}}</article>{{end}}
+{{if .Iframe}}<iframe class="passthrough" src="{{.Iframe}}"></iframe>{{end}}
+</body>
+</html>
+`))
+
+// Page executes the page chrome template.
+func Page(w io.Writer, pd PageData) error {
+	return pageTmpl.Execute(w, pd)
+}
diff --git a/slug.go b/display/slug.go
similarity index 99%
rename from slug.go
rename to display/slug.go
index ac36e22..c537162 100644
--- a/slug.go
+++ b/display/slug.go
@@ -1,4 +1,4 @@
-package main
+package display
 
 import (
 	"strconv"
diff --git a/display/slug_test.go b/display/slug_test.go
new file mode 100644
index 0000000..85546c9
--- /dev/null
+++ b/display/slug_test.go
@@ -0,0 +1,20 @@
+package display
+
+import (
+	"testing"
+)
+
+func TestSlugify(t *testing.T) {
+	cases := map[string]string{
+		"What the verifier catches": "what-the-verifier-catches",
+		"Use before definition":     "use-before-definition",
+		"C++ / FFI (part 2)":        "c-ffi-part-2",
+		"  spaces  everywhere  ":    "spaces-everywhere",
+		"???":                       "",
+	}
+	for in, want := range cases {
+		if got := slugify(in); got != want {
+			t.Errorf("slugify(%q) = %q, want %q", in, got, want)
+		}
+	}
+}
diff --git a/style.css b/display/style.css
similarity index 100%
rename from style.css
rename to display/style.css
diff --git a/theme.go b/display/theme.go
similarity index 95%
rename from theme.go
rename to display/theme.go
index 3e5f92a..03d9521 100644
--- a/theme.go
+++ b/display/theme.go
@@ -1,4 +1,4 @@
-package main
+package display
 
 import (
 	"fmt"
@@ -13,14 +13,14 @@ import (
 // (colorblind users, and nearby hues look alike anyway).
 
 // theme is one host's visual identity, computed once at startup.
-type theme struct {
+type Theme struct {
 	Name string // the seed itself, shown in the nav next to the site name
 	CSS  []byte // ":root" accent override (light and dark), appended after the stylesheet by the /-/style.css handler
 }
 
-func newTheme(seed string) theme {
+func NewTheme(seed string) Theme {
 	hue := themeHue(seed)
-	return theme{
+	return Theme{
 		Name: seed,
 		CSS: []byte(fmt.Sprintf(
 			":root { --accent: hsl(%d 70%% %d%%); }\n"+
@@ -32,7 +32,7 @@ func newTheme(seed string) theme {
 // 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 {
+func HostLabel(hostname string) string {
 	label, _, _ := strings.Cut(hostname, ".")
 	return label
 }
diff --git a/display/theme_test.go b/display/theme_test.go
new file mode 100644
index 0000000..1ca26f2
--- /dev/null
+++ b/display/theme_test.go
@@ -0,0 +1,56 @@
+package display
+
+import (
+	"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)
+		}
+	}
+}
diff --git a/forward_test.go b/forward_test.go
index 29396f3..6ff79e8 100644
--- a/forward_test.go
+++ b/forward_test.go
@@ -9,6 +9,8 @@ import (
 	"path/filepath"
 	"strings"
 	"testing"
+
+	"vitrine/display"
 )
 
 // echoBackend is a test backend that reports the path and query it saw,
@@ -33,7 +35,7 @@ func forwardServer(t *testing.T, forwards map[string]string) *server {
 		}
 		fwds[name] = forward{To: to, url: u}
 	}
-	return newServer(&config{Forwards: fwds}, theme{})
+	return newServer(&config{Forwards: fwds}, display.Theme{})
 }
 
 func TestForwardStripsPrefixKeepsQuery(t *testing.T) {
@@ -105,7 +107,7 @@ func TestForwardRootYieldsToMounts(t *testing.T) {
 	srv := newServer(&config{
 		Exhibits: map[string]exhibit{"meta/research": {Path: filepath.Join(dir, "research")}},
 		Forwards: map[string]forward{"": {To: b.URL, url: u}},
-	}, theme{})
+	}, display.Theme{})
 
 	// Inside the exhibit: the tree serves, not the backend.
 	if body := get(t, srv, "/meta/research/notes.md?fmt=raw").Body.String(); body != "# exhibit wins\n" {
diff --git a/main.go b/main.go
index b46b007..d6f9cea 100644
--- a/main.go
+++ b/main.go
@@ -17,6 +17,8 @@ import (
 	"net/http"
 	"os"
 	"time"
+
+	"vitrine/display"
 )
 
 func main() {
@@ -34,13 +36,13 @@ func main() {
 		if err != nil {
 			log.Fatalf("cannot determine hostname for theming (use -theme-seed to bypass): %v", err)
 		}
-		seed = hostLabel(hostname)
+		seed = display.HostLabel(hostname)
 	}
 	cfg, err := loadConfig(*configPath)
 	if err != nil {
 		log.Fatal(err)
 	}
-	srv := newServer(cfg, newTheme(seed))
+	srv := newServer(cfg, display.NewTheme(seed))
 
 	// Config changes apply live: reloads are fail-stable
 	// (a malformed config logs loudly and the old exhibits keep serving).
diff --git a/render.go b/render.go
index f133a32..08acf95 100644
--- a/render.go
+++ b/render.go
@@ -1,135 +1,45 @@
 package main
 
 import (
-	"bytes"
-	_ "embed"
 	"fmt"
-	"html/template"
 	"net/http"
 	"os"
 	"strings"
 
-	"github.com/yuin/goldmark"
-	gmext "github.com/yuin/goldmark/extension"
-	"github.com/yuin/goldmark/parser"
-	gmutil "github.com/yuin/goldmark/util"
+	"vitrine/display"
 )
 
-// maxRenderSize caps how much we'll read into memory for rendered views.
-// Bigger files still work fine via ?fmt=raw, which streams.
-const maxRenderSize = 4 << 20
-
-var markdown = goldmark.New(
-	goldmark.WithExtensions(gmext.GFM, callouts{}, diagrams{}),
-	goldmark.WithParserOptions(
-		parser.WithAutoHeadingID(),
-		parser.WithASTTransformers(gmutil.Prioritized(headingAnchors{}, 100)),
-	),
-)
-
-//go:embed style.css
-var styleCSS []byte
-
-type dirEntry struct {
-	Name string
-	Href string
-}
-
-type crumb struct {
-	Name string
-	Href string
-}
-
-type pageData struct {
-	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
-// (no leading slash); rel of "." means the mount root itself.
-func joinURL(mount, rel string) string {
-	if rel == "." {
-		return mount
-	}
-	return mount + "/" + rel
-}
-
-// crumbs builds a breadcrumb trail from a URL path (no leading slash):
-// home, then each path segment.  The last segment gets no href;
-// it's the page being viewed.
-func crumbs(urlpath string) []crumb {
-	cs := []crumb{{Name: "vitrine", Href: "/"}}
-	if urlpath == "" {
-		cs[0].Href = ""
-		return cs
-	}
-	href := "/"
-	for _, seg := range strings.Split(urlpath, "/") {
-		href += seg + "/"
-		cs = append(cs, crumb{Name: seg, Href: href})
-	}
-	cs[len(cs)-1].Href = ""
-	return cs
-}
-
 // serveRendered shows a file as a page: markdown rendered to HTML,
 // anything else passed through in an iframe pointed at the raw view.
 func (s *server) serveRendered(w http.ResponseWriter, r *http.Request, root *os.Root, mount, rel string, size int64) {
-	pd := pageData{
+	pd := display.PageData{
 		Title:   mount + "/" + rel,
-		Crumbs:  crumbs(joinURL(mount, rel)),
+		Crumbs:  display.Crumbs("vitrine", display.JoinURL(mount, rel)),
 		RawHref: "?fmt=raw",
 	}
-	if strings.HasSuffix(rel, ".md") && size <= maxRenderSize {
+	if strings.HasSuffix(rel, ".md") && size <= display.MaxRenderSize {
 		src, err := root.ReadFile(rel)
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
 		}
-		var buf bytes.Buffer
-		// A fresh IDs registry per document keeps duplicate-heading suffixes deterministic.
-		ctx := parser.NewContext(parser.WithIDs(newSlugIDs()))
-		if err := markdown.Convert(src, &buf, parser.WithContext(ctx)); err != nil {
+		content, err := display.Markdown(src)
+		if err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
 		}
-		pd.Content = template.HTML(buf.String())
+		pd.Content = content
 	} else {
 		pd.Iframe = "?fmt=raw"
 	}
 	s.renderPage(w, pd)
 }
 
-var pageTmpl = template.Must(template.New("page").Parse(`<!doctype html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>{{.Title}}</title>
-<link rel="stylesheet" href="/-/style.css">
-</head>
-<body>
-<nav>
-{{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}}
-{{if .Content}}<article>{{.Content}}</article>{{end}}
-{{if .Iframe}}<iframe class="passthrough" src="{{.Iframe}}"></iframe>{{end}}
-</body>
-</html>
-`))
-
 // renderPage fills in the server-wide theme name and executes the page template.
-func (s *server) renderPage(w http.ResponseWriter, pd pageData) {
+func (s *server) renderPage(w http.ResponseWriter, pd display.PageData) {
 	pd.ThemeName = s.theme.Name
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
-	if err := pageTmpl.Execute(w, pd); err != nil {
+	if err := display.Page(w, pd); err != nil {
 		fmt.Fprintf(w, "\n<!-- template error: %v -->", err)
 	}
 }
diff --git a/render_test.go b/render_test.go
new file mode 100644
index 0000000..c531755
--- /dev/null
+++ b/render_test.go
@@ -0,0 +1,65 @@
+package main
+
+import (
+	"net/http"
+	"strings"
+	"testing"
+)
+
+func TestHeadingAnchorsInRenderedPage(t *testing.T) {
+	srv := newTestServer(t)
+	rec := get(t, srv, "/demo/README.md")
+	body := rec.Body.String()
+	if !strings.Contains(body, `<h1 id="hello">`) {
+		t.Errorf("heading missing slug id; body:\n%s", body)
+	}
+	if !strings.Contains(body, `<a href="#hello" class="anchor">`) {
+		t.Errorf("heading missing chainlink anchor; body:\n%s", body)
+	}
+}
+
+func TestDuplicateHeadingsGetDistinctIDs(t *testing.T) {
+	srv := newTestServer(t)
+	// dup.md is written by newTestServer with two identical headings.
+	rec := get(t, srv, "/demo/dup.md")
+	body := rec.Body.String()
+	if !strings.Contains(body, `id="twice"`) || !strings.Contains(body, `id="twice-2"`) {
+		t.Errorf("duplicate headings not disambiguated; body:\n%s", body)
+	}
+}
+
+func TestStylesheetServed(t *testing.T) {
+	rec := get(t, newTestServer(t), "/-/style.css")
+	if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") {
+		t.Errorf("status = %d, content-type = %q", rec.Code, rec.Header().Get("Content-Type"))
+	}
+}
+
+// TestStylesheetCarriesThemeAccent checks that /-/style.css ends with the
+// computed :root override (light and dark), after the embedded stylesheet:
+// at equal specificity the later declaration wins,
+// so appending (not prepending) is what makes the theme accent take effect.
+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%/70%.
+	want := ":root { --accent: hsl(351 70% 35%); }\n" +
+		"@media (prefers-color-scheme: dark) { :root { --accent: hsl(351 70% 70%); } }\n"
+	if !strings.HasSuffix(body, want) {
+		t.Errorf("stylesheet does not end with theme override %q; tail: %q", want, body[max(0, len(body)-200):])
+	}
+	if !strings.Contains(body, "--paper") {
+		t.Errorf("embedded stylesheet missing before 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/serve.go b/serve.go
index f807c0a..699860c 100644
--- a/serve.go
+++ b/serve.go
@@ -12,6 +12,8 @@ import (
 	"sort"
 	"strings"
 	"sync/atomic"
+
+	"vitrine/display"
 )
 
 // server holds the currently mounted exhibits and forwards behind an atomic pointer,
@@ -21,7 +23,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
+	theme  display.Theme // fixed for the process lifetime; see display/theme.go
 }
 
 // mountSet holds everything served under mount names (URL prefixes):
@@ -43,7 +45,7 @@ type tree struct {
 	raw       bool
 }
 
-func newServer(cfg *config, th theme) *server {
+func newServer(cfg *config, th display.Theme) *server {
 	s := &server{theme: th}
 	s.setMounts(cfg)
 	return s
@@ -146,7 +148,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		// The theme's :root block follows the embedded stylesheet:
 		// at equal specificity the later declaration wins,
 		// so appending is what lets the theme's --accent override the stylesheet's default.
-		w.Write(styleCSS)
+		w.Write(display.StyleCSS)
 		w.Write(s.theme.CSS)
 		return
 	}
@@ -217,7 +219,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		if req != "" {
 			title = req + "/"
 		}
-		s.renderPage(w, pageData{Title: title, Crumbs: crumbs(req), Listing: kids})
+		s.renderPage(w, display.PageData{Title: title, Crumbs: display.Crumbs("vitrine", req), Listing: kids})
 		return
 	}
 
@@ -235,7 +237,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 // forwards alike) under the given prefix.
 // Returns nil if no mount lives under this prefix.
 // The root forward (empty name) never appears: it has no name to list.
-func mountChildren(mounts *mountSet, prefix string) []dirEntry {
+func mountChildren(mounts *mountSet, prefix string) []display.DirEntry {
 	seen := make(map[string]bool)
 	note := func(mount string) {
 		rest := mount
@@ -264,12 +266,12 @@ func mountChildren(mounts *mountSet, prefix string) []dirEntry {
 		names = append(names, seg)
 	}
 	sort.Strings(names)
-	entries := make([]dirEntry, 0, len(names)+1)
+	entries := make([]display.DirEntry, 0, len(names)+1)
 	if prefix != "" {
-		entries = append(entries, dirEntry{Name: "../", Href: "../"})
+		entries = append(entries, display.DirEntry{Name: "../", Href: "../"})
 	}
 	for _, seg := range names {
-		entries = append(entries, dirEntry{Name: seg + "/", Href: seg + "/"})
+		entries = append(entries, display.DirEntry{Name: seg + "/", Href: seg + "/"})
 	}
 	return entries
 }
@@ -374,8 +376,8 @@ func (s *server) serveDir(w http.ResponseWriter, r *http.Request, tr tree, mount
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	var listing []dirEntry
-	listing = append(listing, dirEntry{Name: "../", Href: "../"})
+	var listing []display.DirEntry
+	listing = append(listing, display.DirEntry{Name: "../", Href: "../"})
 	for _, e := range ents {
 		if e.Name() == ".git" && !tr.exposeGit {
 			continue
@@ -384,11 +386,11 @@ func (s *server) serveDir(w http.ResponseWriter, r *http.Request, tr tree, mount
 		if e.IsDir() {
 			n += "/"
 		}
-		listing = append(listing, dirEntry{Name: n, Href: n})
+		listing = append(listing, display.DirEntry{Name: n, Href: n})
 	}
-	s.renderPage(w, pageData{
+	s.renderPage(w, display.PageData{
 		Title:   mount + "/" + strings.TrimSuffix(rel, "."),
-		Crumbs:  crumbs(joinURL(mount, rel)),
+		Crumbs:  display.Crumbs("vitrine", display.JoinURL(mount, rel)),
 		Listing: listing,
 	})
 }
diff --git a/serve_test.go b/serve_test.go
index 029acec..e53828a 100644
--- a/serve_test.go
+++ b/serve_test.go
@@ -8,10 +8,12 @@ import (
 	"path/filepath"
 	"strings"
 	"testing"
+
+	"vitrine/display"
 )
 
 // exhibitServer builds a server around exhibits only, no forwards.
-func exhibitServer(exhibits map[string]exhibit, th theme) *server {
+func exhibitServer(exhibits map[string]exhibit, th display.Theme) *server {
 	return newServer(&config{Exhibits: exhibits}, th)
 }
 
@@ -34,7 +36,7 @@ func newTestServer(t *testing.T) *server {
 			t.Fatal(err)
 		}
 	}
-	srv := exhibitServer(map[string]exhibit{"demo": {Path: dir}}, newTheme("testhost"))
+	srv := exhibitServer(map[string]exhibit{"demo": {Path: dir}}, display.NewTheme("testhost"))
 	return srv
 }
 
@@ -91,7 +93,7 @@ func TestImageFormatsServeRawByDefault(t *testing.T) {
 			t.Fatal(err)
 		}
 	}
-	srv := exhibitServer(map[string]exhibit{"img": {Path: dir}}, theme{})
+	srv := exhibitServer(map[string]exhibit{"img": {Path: dir}}, display.Theme{})
 
 	// These should serve raw bytes, no iframe.
 	for _, name := range []string{"icon.png", "drawing.svg", "photo.jpg"} {
@@ -128,7 +130,7 @@ func newRawTestServer(t *testing.T) *server {
 			t.Fatal(err)
 		}
 	}
-	return exhibitServer(map[string]exhibit{"demo": {Path: dir, Raw: true}}, newTheme("testhost"))
+	return exhibitServer(map[string]exhibit{"demo": {Path: dir, Raw: true}}, display.NewTheme("testhost"))
 }
 
 // On a raw mount, markdown serves as its plain bytes, not rendered HTML:
@@ -232,7 +234,7 @@ func newNestedTestServer(t *testing.T) *server {
 	}
 	srv := exhibitServer(map[string]exhibit{
 		"meta/research/latest": {Path: filepath.Join(dir, "research/latest")},
-	}, theme{})
+	}, display.Theme{})
 	return srv
 }
 
@@ -279,7 +281,7 @@ func TestNestedMountWinsOverParent(t *testing.T) {
 	srv := exhibitServer(map[string]exhibit{
 		"docs":      {Path: filepath.Join(dir, "parent")},
 		"docs/deep": {Path: filepath.Join(dir, "child")},
-	}, theme{})
+	}, display.Theme{})
 	for i := 0; i < 20; i++ {
 		rec := get(t, srv, "/docs/deep/note.md?fmt=raw")
 		if got := rec.Body.String(); got != "from child mount\n" {
@@ -347,7 +349,7 @@ func newExposeGitTestServer(t *testing.T) (*server, string) {
 			t.Fatalf("git %v: %v: %s", args, err, out)
 		}
 	}
-	srv := exhibitServer(map[string]exhibit{"repo": {Path: dir, ExposeGit: true}}, theme{})
+	srv := exhibitServer(map[string]exhibit{"repo": {Path: dir, ExposeGit: true}}, display.Theme{})
 	return srv, dir
 }
 
diff --git a/slug_test.go b/slug_test.go
deleted file mode 100644
index 55a0050..0000000
--- a/slug_test.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package main
-
-import (
-	"strings"
-	"testing"
-)
-
-func TestSlugify(t *testing.T) {
-	cases := map[string]string{
-		"What the verifier catches": "what-the-verifier-catches",
-		"Use before definition":     "use-before-definition",
-		"C++ / FFI (part 2)":        "c-ffi-part-2",
-		"  spaces  everywhere  ":    "spaces-everywhere",
-		"???":                       "",
-	}
-	for in, want := range cases {
-		if got := slugify(in); got != want {
-			t.Errorf("slugify(%q) = %q, want %q", in, got, want)
-		}
-	}
-}
-
-func TestHeadingAnchorsInRenderedPage(t *testing.T) {
-	srv := newTestServer(t)
-	rec := get(t, srv, "/demo/README.md")
-	body := rec.Body.String()
-	if !strings.Contains(body, `<h1 id="hello">`) {
-		t.Errorf("heading missing slug id; body:\n%s", body)
-	}
-	if !strings.Contains(body, `<a href="#hello" class="anchor">`) {
-		t.Errorf("heading missing chainlink anchor; body:\n%s", body)
-	}
-}
-
-func TestDuplicateHeadingsGetDistinctIDs(t *testing.T) {
-	srv := newTestServer(t)
-	// dup.md is written by newTestServer with two identical headings.
-	rec := get(t, srv, "/demo/dup.md")
-	body := rec.Body.String()
-	if !strings.Contains(body, `id="twice"`) || !strings.Contains(body, `id="twice-2"`) {
-		t.Errorf("duplicate headings not disambiguated; body:\n%s", body)
-	}
-}
-
-func TestStylesheetServed(t *testing.T) {
-	rec := get(t, newTestServer(t), "/-/style.css")
-	if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") {
-		t.Errorf("status = %d, content-type = %q", rec.Code, rec.Header().Get("Content-Type"))
-	}
-}
diff --git a/theme_test.go b/theme_test.go
deleted file mode 100644
index 4de0452..0000000
--- a/theme_test.go
+++ /dev/null
@@ -1,87 +0,0 @@
-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 ends with the
-// computed :root override (light and dark), after the embedded stylesheet:
-// at equal specificity the later declaration wins,
-// so appending (not prepending) is what makes the theme accent take effect.
-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%/70%.
-	want := ":root { --accent: hsl(351 70% 35%); }\n" +
-		"@media (prefers-color-scheme: dark) { :root { --accent: hsl(351 70% 70%); } }\n"
-	if !strings.HasSuffix(body, want) {
-		t.Errorf("stylesheet does not end with theme override %q; tail: %q", want, body[max(0, len(body)-200):])
-	}
-	if !strings.Contains(body, "--paper") {
-		t.Errorf("embedded stylesheet missing before 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 e0cd821..baa25c7 100644
--- a/watch_test.go
+++ b/watch_test.go
@@ -7,7 +7,9 @@ import (
 	"path/filepath"
 	"sync/atomic"
 	"testing"
+
 	"time"
+	"vitrine/display"
 )
 
 // watchFixture is a server plus a watched config file in a temp dir,
@@ -40,7 +42,7 @@ func newWatchFixture(t *testing.T, pollInterval time.Duration) *watchFixture {
 	if err != nil {
 		t.Fatal(err)
 	}
-	fx.srv = newServer(cfg, theme{})
+	fx.srv = newServer(cfg, display.Theme{})
 	watchConfig(fx.cfgPath, pollInterval, func() {
 		reloadConfig(fx.srv, fx.cfgPath)
 		fx.checked.Add(1)