socle

chrome.go

package main

import (
	"fmt"
	"html/template"
	"log"
	"math"
	"net/http"
	"strings"

	"vitrine/display"
)

// This file is socle's page chrome: the gallery-sketchbook skin.
// Every page is an exhibit sheet: hatched flanks at the margins, a crumb trail,
// an arc of round buttons over the content, and the gallery floor at the bottom.
// All texture comes from the sketch assets (sketch.go); there is no JavaScript.

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

// arcButton is one round button on the arc.  Buttons are for moves; prose links stay links.
type arcButton struct {
	Label   string // one short word, drawn inside the circle
	Title   string // the fuller hint, for the title attribute
	Href    string
	Variant string // "a", "b", or "c": which circle drawing the button wears
	DY      int    // the button's drop below the arc apex, in px; computed, static per page
}

// arcLayout drops each button onto a circle arc: the row is centered, and each button
// sinks below the apex by the circle's sagitta at its x position.  Computed server-side
// so positions are static per page type (buttons must not move around).
// Drawing variants cycle a/b/c so neighbors never wear the same circle.
func arcLayout(radius, spacing float64, btns []arcButton) []arcButton {
	n := len(btns)
	for i := range btns {
		x := (float64(i) - float64(n-1)/2) * spacing
		btns[i].DY = int(radius - math.Sqrt(radius*radius-x*x) + 0.5)
		btns[i].Variant = string(rune('a' + i%3))
	}
	return btns
}

// The spacing passed to arcLayout is the distance between button centers, so it must
// equal the button diameter plus the flex gap in the stylesheet (nav.arc rules).

// dawnArc is the repo home's full arc: the sun of moves rising over the exhibit.
// 88px buttons + 2.4rem gap.
func dawnArc(btns []arcButton) []arcButton { return arcLayout(300, 126, btns) }

// cornerArc is the compact contextual arc every other page gets.
// 58px buttons + 1.1rem gap.
func cornerArc(btns []arcButton) []arcButton { return arcLayout(300, 76, btns) }

var soclePageTmpl = template.Must(template.New("socle").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>
<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}}
</nav>
{{if .Arc}}<nav class="arc{{if not .Dawn}} corner{{end}}" aria-label="page moves" style="padding-bottom:calc(.8rem + {{.ArcDrop}}px)">
{{range .Arc}}<a class="v{{.Variant}}" href="{{.Href}}"{{if .Title}} title="{{.Title}}"{{end}} style="--dy:{{.DY}}px">{{.Label}}</a>
{{end}}</nav>
{{end}}<main><article>{{.Content}}</article></main>
</div>
<footer class="ground">
<div class="floor"></div>
<p class="colophon">{{.ThemeName}} · 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]
	if !ok {
		http.Error(w, "no such sketch asset", http.StatusNotFound)
		return
	}
	w.Header().Set("Content-Type", "image/svg+xml")
	w.Header().Set("Cache-Control", "public, max-age=604800")
	w.Write(b)
}

// socleCSS is the scheme-independent part of the sketchbook stylesheet, appended after
// display.StyleCSS in /-/style.css.  Rules that reference sketch assets live in
// sketchCSS, which is emitted once per color scheme.
const socleCSS = `
/* ---- socle: the gallery sketchbook ---- */

/* socle lays out its own page: a full-width column so the floor can span the viewport,
   with the sheet (the exhibit column) centered inside it. */
body {
	max-width: none;
	margin: 0;
	padding: 0;
	display: flex;
	flex-direction: column;
	min-height: 100vh;
}
.sheet {
	width: 100%;
	max-width: 64rem;
	margin: 0 auto;
	padding: 1rem 1.25rem 0;
	box-sizing: border-box;
	flex: 1;
}

/* The hatched flanks: texture, not content.  Fixed, behind everything, unclickable,
   and gone on narrow viewports where they would crowd the sheet. */
.flank {
	position: fixed;
	top: 0;
	bottom: 0;
	width: 240px;
	pointer-events: none;
	z-index: -1;
	background-repeat: repeat-y;
	background-size: 240px 720px;
}
.flank.left { left: 0; }
.flank.right { right: 0; transform: scaleX(-1); }
/* Below this, a flank's inner (already faded) end would still reach under the sheet. */
@media (max-width: 1550px) { .flank { display: none; } }

/* The arc: round buttons over the exhibit.  Offsets along the arc are computed
   server-side into --dy; hover swaps the circle drawing, never the position. */
nav.arc {
	display: flex;
	justify-content: center;
	align-items: flex-start;
	gap: 2.4rem;
	border: none;
	margin: 0;
	padding: 2.4rem 0 1.4rem;
	background: none;
}
nav.arc a {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 88px;
	height: 88px;
	border-radius: 50%;
	text-decoration: none;
	color: var(--ink);
	font-size: .85rem;
	letter-spacing: .04em;
	background-position: center;
	background-size: 100% 100%;
	background-repeat: no-repeat;
	transform: translateY(var(--dy, 0));
}
nav.arc a:hover { color: var(--accent); }
nav.arc a:focus-visible {
	outline: 3px solid var(--accent);
	outline-offset: 3px;
}
nav.arc.corner {
	justify-content: flex-end;
	gap: 1.1rem;
	padding: 1.1rem 0 .6rem;
}
nav.arc.corner a {
	width: 58px;
	height: 58px;
	font-size: .72rem;
}

/* The tagline: the repo description, read as the exhibit's subtitle. */
article .tagline {
	text-align: center;
	font-style: italic;
	color: var(--ink-soft);
	margin: 0 0 .75rem;
}

/* The clone box and branch list sit centered under the plaque, like gallery cards. */
article p.clone, article #branches { text-align: center; margin-left: auto; margin-right: auto; }
article p.clone.boxed { display: block; width: fit-content; }

/* The plaque: museum-label typography for the metadata line under an exhibit. */
.plaque {
	text-align: center;
	font-size: .8rem;
	letter-spacing: .16em;
	color: var(--ink-soft);
	margin: .9rem auto 2.2rem;
}
article .plaque { margin-left: auto; }
article .plaque code { background: none; padding: 0; letter-spacing: .06em; }

/* Exhibit interiors.  A file view's whole body is one pre (pre.file): there the frame
   is the box, so that pre drops its well.  Code blocks inside a framed README keep
   theirs; they are exhibits' content, not the exhibit itself. */
.exhibit { margin-top: .5rem; }
article .exhibit { margin-left: 0; }
.exhibit > :first-child { margin-top: 0; }
.exhibit > :last-child { margin-bottom: 0; }
article .exhibit > pre.file {
	margin-left: 0;
	background: none;
	border: none;
	padding: 0;
}

/* The ground: the gallery floor at the end of every page. */
footer.ground { margin-top: 4rem; }
footer.ground .floor {
	height: 160px;
	background-position: bottom center;
	background-size: 1800px 160px;
	background-repeat: no-repeat;
}
footer.ground .colophon {
	text-align: center;
	font-size: .78rem;
	letter-spacing: .16em;
	color: var(--ink-soft);
	margin: .5rem 0 1.1rem;
}

/* Listing and log tables. */
article table.tree td, article table.tree th,
article table.log td, article table.log th { border: none; padding: .15rem .6rem; }
article table.tree tbody tr:hover, article table.log tbody tr:hover { background: var(--well); }
article th { text-align: left; background: none; }
/* Table links skip the underline: in a column of links it is pure noise;
   the accent color and the row hover carry the affordance.  Hover restores it. */
article table.tree a, article table.log a { text-decoration: none; }
article table.tree a:hover, article table.log a:hover { text-decoration: underline; }
article td.name { white-space: nowrap; }
article td.size { text-align: right; color: var(--ink-soft); white-space: nowrap; }
article td.date { color: var(--ink-soft); white-space: nowrap; }
article td.soft { color: var(--ink-soft); }
article td.hash a { font-family: monospace; }
article p.clone code { user-select: all; }
article img.file { max-width: 100%; }
article pre.file { tab-size: 4; }
/* Unified diffs color by text, not background: safe in both schemes, and the
   hues are the palette's existing success/danger pair. */
article pre.diff { tab-size: 4; }
article pre.diff .d-add { color: var(--success); }
article pre.diff .d-del { color: var(--danger); }
article pre.diff .d-hunk { color: var(--accent); }
article pre.diff .d-file { font-weight: bold; }
`

// sketchCSS emits the rules that reference sketch assets, for one color scheme.
// Written once so light and dark cannot drift apart; the caller wraps the dark copy
// in its media query.
func sketchCSS(scheme string) string {
	u := func(name string) string { return "url(/-/sketch/" + name + "-" + scheme + ".svg)" }
	return fmt.Sprintf(`
.flank { background-image: %s; }
footer.ground .floor { background-image: %s; }
/* Hand-ruled lines replace solid borders on the nav and headings. */
nav.crumbs, article h1, article h2 {
	border-bottom: none;
	background-image: %s;
	background-position: bottom left;
	background-repeat: repeat-x;
	background-size: auto 10px;
	padding-bottom: .55rem;
}
/* Rough-ruled frames, as border-images.  The tile is sliced at 16 and the border is
   near that size, so the stroke renders at the weight it was drawn. */
.exhibit {
	border: 13px solid transparent;
	border-image: %s 16 / 13px round;
	padding: 1.4rem 2rem;
}
.boxed {
	border: 10px solid transparent;
	border-image: %s 16 / 10px round;
	padding: .3rem .9rem;
	display: inline-block;
}
/* Each button wears one of three circle drawings; hover moves to the next drawing,
   so the ink appears to shift under a living hand.  Active presses to the third. */
nav.arc a.va { background-image: %s; }
nav.arc a.vb { background-image: %s; }
nav.arc a.vc { background-image: %s; }
nav.arc a.va:hover { background-image: %s; }
nav.arc a.vb:hover { background-image: %s; }
nav.arc a.vc:hover { background-image: %s; }
nav.arc a.va:active { background-image: %s; }
nav.arc a.vb:active { background-image: %s; }
nav.arc a.vc:active { background-image: %s; }
`,
		u("flank"), u("ground"), u("rule"), u("frame"), u("frame"),
		u("circle-a"), u("circle-b"), u("circle-c"),
		u("circle-b"), u("circle-c"), u("circle-a"),
		u("circle-c"), u("circle-a"), u("circle-b"))
}

// buildStyle assembles the full /-/style.css body: vitrine's base sheet, socle's
// sketchbook sheet, the per-scheme sketch rules, and the host theme's :root override
// last (at equal specificity the later declaration wins).
func buildStyle(theme display.Theme) []byte {
	var sb strings.Builder
	sb.Write(display.StyleCSS)
	sb.WriteString(socleCSS)
	sb.WriteString(sketchCSS("light"))
	sb.WriteString("@media (prefers-color-scheme: dark) {")
	sb.WriteString(sketchCSS("dark"))
	sb.WriteString("}\n")
	sb.Write(theme.CSS)
	return []byte(sb.String())
}

// renderPage fills in the site-wide theme 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)
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	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)
		fmt.Fprintf(w, "\n<!-- template error: %v -->", err)
	}
}

chrome.go · 11.8 KiB · 68bc19f54a