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 · 370d8f1c9f