socle

the empty room: 404s happen in character

commit fba503ffd6f9984ba70ba76bfdcf6adbd4d717f5 · parent 30aaaf206d · browse this tree
Ximianth, 2026-10-30 14:49:00 UTC

Unknown repos, stale refs, vanished paths, and submodule pointers render as
a themed page on the browse surfaces: the bare pedestal (the same mark the
footer uses for 'nothing more on display'), the specific reason in the
museum-label register, and a button back to the entrance.  Status stays 404.
The raw and git-protocol endpoints keep terse plain text: their audience is
tools.  400s and 500s stay plain everywhere -- faults are not rooms.
diff --git a/chrome.go b/chrome.go
index b3a80ea..3730975 100644
--- a/chrome.go
+++ b/chrome.go
@@ -25,6 +25,7 @@ type pageData struct {
 	// deeper pages are destinations, not cards.
 	Desc    string
 	Canon   string // the canonical URL for og:url, when a baseurl is configured
+	Status  int    // non-zero substitutes the response status (the empty-room 404)
 	Crumbs  []display.Crumb
 	Arc     []arcButton
 	ArcDrop int  // the deepest button drop, so the arc region reserves room for it (transforms take no layout space)
@@ -264,6 +265,24 @@ article ul.entrance {
 	text-overflow: ellipsis;
 }
 
+/* The empty room: a 404 shows the bare pedestal, the reason in the museum-label
+   register, and the way back.  The pedestal already means "nothing on display"
+   wherever else it stands empty; here that is the whole message. */
+article .emptyroom { text-align: center; margin: 3.5rem 0; }
+article .emptyroom .pedestal {
+	height: 84px;
+	background-position: top center;
+	background-size: 140px 84px;
+	background-repeat: no-repeat;
+}
+article .emptyroom .nothing {
+	font-size: .8rem;
+	letter-spacing: .16em;
+	color: var(--ink-soft);
+	margin: 1.2rem 0 2.4rem;
+}
+article .emptyroom .back a.boxed { text-decoration: none; padding: .45rem 1.1rem; }
+
 /* The tagline: the repo description, read as the exhibit's subtitle. */
 article .tagline {
 	text-align: center;
@@ -466,7 +485,7 @@ func sketchCSS(scheme string) string {
 	return fmt.Sprintf(`
 .flank { background-image: %s; }
 footer.ground .floor { background-image: %s; }
-.stand .pedestal { background-image: %s; }
+.stand .pedestal, .emptyroom .pedestal { background-image: %s; }
 .marquee .pedestal { background-image: %s; }
 /* Hand-ruled lines replace solid borders on the nav and headings. */
 nav.crumbs, article h1, article h2 {
@@ -549,6 +568,9 @@ func (s *server) renderPage(w http.ResponseWriter, pd pageData) {
 		pd.ArcDrop = max(pd.ArcDrop, b.DY)
 	}
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	if pd.Status != 0 {
+		w.WriteHeader(pd.Status)
+	}
 	if err := soclePageTmpl.Execute(w, pd); err != nil {
 		// Headers are gone already; leave a trace in the body and the log.
 		log.Print("socle: ", err)
diff --git a/pages.go b/pages.go
index 129e5e5..edf48c0 100644
--- a/pages.go
+++ b/pages.go
@@ -146,6 +146,26 @@ func (s *server) serveIndex(w http.ResponseWriter, r *http.Request, snap *snapsh
 	}{snap.face.siteName, rows}, pd)
 }
 
+// ---- the empty room (404) ----
+
+// A 404 is an address with nothing on display: one bare pedestal (the footer's
+// "nothing more" mark, center-room), the reason in the museum-label register,
+// and the way back to the entrance.
+var notFoundTmpl = template.Must(template.New("notfound").Parse(`<div class="emptyroom">
+<div class="pedestal"></div>
+<p class="nothing">{{.Reason}}</p>
+<p class="back"><a class="boxed" href="/">to the entrance</a></p>
+</div>
+`))
+
+func (s *server) notFoundPage(w http.ResponseWriter, snap *snapshot, reason string) {
+	s.render(w, snap, notFoundTmpl, struct{ Reason string }{reason}, pageData{
+		Title:  "nothing here · " + snap.face.siteName,
+		Status: http.StatusNotFound,
+		Crumbs: []display.Crumb{{Name: snap.face.siteName, Href: "/"}, {Name: "?"}},
+	})
+}
+
 // ---- repo home ----
 
 // The repo home: the vitals come first -- the clone line and the language meter in one
@@ -245,7 +265,7 @@ func (s *server) serveHome(w http.ResponseWriter, r *http.Request, snap *snapsho
 	}
 	branches, tags, err := rp.refs()
 	if err != nil {
-		httpError(w, err)
+		s.pageError(w, snap, err)
 		return
 	}
 	for _, b := range branches {
@@ -260,20 +280,20 @@ func (s *server) serveHome(w http.ResponseWriter, r *http.Request, snap *snapsho
 		var rm readmeInfo
 		d.Readme, rm, err = s.renderReadme(rp, db, sha)
 		if err != nil {
-			httpError(w, err)
+			s.pageError(w, snap, err)
 			return
 		}
 		d.ReadmeName, d.ReadmeSize, d.ShortSha = rm.name, humanSize(rm.size), shortSha(sha)
 		stats, err := s.langStats(rp, sha)
 		if err != nil {
-			httpError(w, err)
+			s.pageError(w, snap, err)
 			return
 		}
 		d.Langs = langSegments(stats)
 	case errors.Is(err, errNotFound):
 		d.Empty = true // an unborn HEAD: a freshly-init'd repo is fine to show, just bare
 	default:
-		httpError(w, err)
+		s.pageError(w, snap, err)
 		return
 	}
 	pd := pageData{
@@ -383,7 +403,7 @@ type treeData struct {
 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)
+		s.pageError(w, snap, err)
 		return
 	}
 	d := treeData{Title: pth, Kicker: rp.name}
@@ -542,7 +562,7 @@ func (s *server) serveFileView(w http.ResponseWriter, snap *snapshot, rp *repo,
 	}
 	size, err := rp.blobSize(sha, pth)
 	if err != nil {
-		httpError(w, err)
+		s.pageError(w, snap, err)
 		return
 	}
 	plq := plaqueData{Path: pth, Size: humanSize(size), ShortSha: shortSha(sha)}
@@ -565,7 +585,7 @@ func (s *server) serveFileView(w http.ResponseWriter, snap *snapshot, rp *repo,
 	}
 	var buf bytes.Buffer
 	if err := rp.catBlob(sha, pth, &buf); err != nil {
-		httpError(w, err)
+		s.pageError(w, snap, err)
 		return
 	}
 	if looksBinary(buf.Bytes()) {
@@ -575,7 +595,7 @@ func (s *server) serveFileView(w http.ResponseWriter, snap *snapshot, rp *repo,
 	if strings.ToLower(path.Ext(pth)) == ".md" {
 		body, err := display.Markdown(buf.Bytes())
 		if err != nil {
-			httpError(w, err)
+			s.pageError(w, snap, err)
 			return
 		}
 		s.render(w, snap, fileTmpl, struct {
@@ -587,7 +607,7 @@ func (s *server) serveFileView(w http.ResponseWriter, snap *snapshot, rp *repo,
 	}
 	var pre bytes.Buffer
 	if err := preTmpl.Execute(&pre, buf.String()); err != nil {
-		httpError(w, err)
+		s.pageError(w, snap, err)
 		return
 	}
 	s.render(w, snap, fileTmpl, struct {
diff --git a/serve.go b/serve.go
index a6c0da1..bf11a3a 100644
--- a/serve.go
+++ b/serve.go
@@ -134,7 +134,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	rp, rest := matchRepo(repos, p)
 	if rp == nil {
-		http.NotFound(w, r)
+		s.notFoundPage(w, snap, "no exhibit at this address")
 		return
 	}
 	// The go toolchain's vanity-import probe outranks all other dispatch under a repo:
@@ -196,9 +196,22 @@ func serveGoGetMeta(w http.ResponseWriter, rp *repo, baseURL string) {
 `, prefix, repoURL, prefix, repoURL, repoURL, repoURL, rp.name, prefix)
 }
 
+// pageError is httpError for the browse surfaces: a not-found renders as the themed
+// empty-room page, since a stale link deserves the same room as a mistyped one.
+// Everything else falls through to the plain mapping -- faults are not rooms.
+func (s *server) pageError(w http.ResponseWriter, snap *snapshot, err error) {
+	if errors.Is(err, errNotFound) {
+		s.notFoundPage(w, snap, err.Error())
+		return
+	}
+	httpError(w, err)
+}
+
 // httpError maps a plumbing-layer error onto an HTTP response:
 // errInvalid is the client's fault (400), errNotFound speaks for itself (404),
 // and anything else is internal (500) -- logged in full, reported generically.
+// The raw and git-protocol endpoints use this directly: their audience is tools,
+// and tools want terse text, not a themed room.
 func httpError(w http.ResponseWriter, err error) {
 	switch {
 	case errors.Is(err, errInvalid):
@@ -233,12 +246,12 @@ func (s *server) serveTree(w http.ResponseWriter, r *http.Request, snap *snapsho
 	ref, path := splitRefPath(arg)
 	sha, err := rp.resolveCommit(ref)
 	if err != nil {
-		httpError(w, err)
+		s.pageError(w, snap, err)
 		return
 	}
 	typ, err := rp.entryType(sha, path)
 	if err != nil {
-		httpError(w, err)
+		s.pageError(w, snap, err)
 		return
 	}
 	setCacheHeader(w, ref, sha)
@@ -249,7 +262,7 @@ func (s *server) serveTree(w http.ResponseWriter, r *http.Request, snap *snapsho
 		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)
+		s.notFoundPage(w, snap, fmt.Sprintf("path %q is a submodule pointer, not browsable here", path))
 	}
 }
 
@@ -296,12 +309,12 @@ func (s *server) serveLog(w http.ResponseWriter, r *http.Request, snap *snapshot
 	ref := strings.TrimSuffix(arg, "/")
 	sha, err := rp.resolveCommit(ref)
 	if err != nil {
-		httpError(w, err)
+		s.pageError(w, snap, err)
 		return
 	}
 	cs, err := rp.commits(sha, logLimit)
 	if err != nil {
-		httpError(w, err)
+		s.pageError(w, snap, err)
 		return
 	}
 	setCacheHeader(w, ref, sha)
@@ -315,17 +328,17 @@ func (s *server) serveCommit(w http.ResponseWriter, r *http.Request, snap *snaps
 	ref := strings.TrimSuffix(arg, "/")
 	sha, err := rp.resolveCommit(ref)
 	if err != nil {
-		httpError(w, err)
+		s.pageError(w, snap, err)
 		return
 	}
 	c, err := rp.commit(sha)
 	if err != nil {
-		httpError(w, err)
+		s.pageError(w, snap, err)
 		return
 	}
 	diff, err := rp.diff(c)
 	if err != nil {
-		httpError(w, err)
+		s.pageError(w, snap, err)
 		return
 	}
 	setCacheHeader(w, ref, sha)
diff --git a/testdata/pages.md b/testdata/pages.md
index cb75185..c523dc4 100644
--- a/testdata/pages.md
+++ b/testdata/pages.md
@@ -642,8 +642,34 @@ invalid: ref "main..dev" contains ".."
 [testmark]:# (err-unknown-repo/response)
 ```text
 status: 404
-Content-Type: text/plain; charset=utf-8
-X-Content-Type-Options: nosniff
+Content-Type: text/html; charset=utf-8
 
-404 page not found
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>nothing here · test</title>
+<link rel="stylesheet" href="/-/style.css">
+<link rel="icon" type="image/svg+xml" href="/-/sketch/favicon.svg">
+</head>
+<body>
+<div class="flank left"></div><div class="flank right"></div>
+<div class="sheet">
+<nav class="crumbs">
+<a href="/">test</a> / ?
+</nav>
+<main><article><div class="emptyroom">
+<div class="pedestal"></div>
+<p class="nothing">no exhibit at this address</p>
+<p class="back"><a class="boxed" href="/">to the entrance</a></p>
+</div>
+</article></main>
+</div>
+<footer class="ground">
+<div class="floor"></div>
+<p class="colophon">test · made with socle</p>
+</footer>
+</body>
+</html>
 ```