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
SiteName string
// Desc, when set, emits the calling-card metas (description + OpenGraph):
// what a pasted link unfurls into. Only the entrance and repo homes carry one;
// 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)
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: each sinks below the arc's 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). apexAt is the apex's x position in button
// indices (i.e. 1.0 means "centered on the second button"): the dawn arc puts the apex at
// the row's center for a symmetric sunrise, and the corner arc puts it just past the
// rightmost button, so every button sits on a visibly rising line -- even a pair of them
// (a symmetric pair straddles the apex at equal height and reads as flat; we learned).
// Drawing variants cycle a/b/c so neighbors never wear the same circle.
func arcLayout(radius, spacing, apexAt float64, btns []arcButton) []arcButton {
for i := range btns {
x := (apexAt - float64(i)) * 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; apex at the row's center.
func dawnArc(btns []arcButton) []arcButton {
return arcLayout(300, 126, float64(len(btns)-1)/2, btns)
}
// cornerArc is the compact contextual arc every other page gets: a rising line toward
// the corner, apex half a spacing past the rightmost button. 58px buttons + 1.1rem gap.
func cornerArc(btns []arcButton) []arcButton {
return arcLayout(380, 76, float64(len(btns)-1)+0.5, 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">
<link rel="icon" type="image/svg+xml" href="/-/sketch/favicon.svg">
{{if .Desc}}<meta name="description" content="{{.Desc}}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="{{.SiteName}}">
<meta property="og:title" content="{{.Title}}">
<meta property="og:description" content="{{.Desc}}">
{{if .Canon}}<meta property="og:url" content="{{.Canon}}">
{{end}}{{end}}</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 if $i}}{{$c.Name}}{{else}}<span class="site">{{$c.Name}}</span>{{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>
{{end}}</nav>
{{end}}<main><article>{{.Content}}</article></main>
</div>
<footer class="ground">
<div class="floor"></div>
<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 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
}
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.
The page emits its deepest drop as --arc-drop, and the bottom padding reserves
that room here (transforms take no layout space); narrow screens flatten the
arc by overriding both, further down. */
nav.arc {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 2.4rem;
border: none;
margin: 0;
padding: 2.4rem 0 calc(.8rem + var(--arc-drop, 0px));
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: .8rem;
letter-spacing: .02em;
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 calc(.8rem + var(--arc-drop, 0px));
}
nav.arc.corner a {
width: 58px;
height: 58px;
font-size: .72rem;
}
/* The nameplate: on the entrance (the one page whose first crumb is unlinked),
the sitename wears the accent -- the gallery's name at the door, and nothing more. */
nav.crumbs .site {
color: var(--accent);
font-weight: 600;
font-size: 1.05rem;
letter-spacing: .05em;
}
/* The entrance: repos as exhibit stands, surveyed rather than parsed.
Each stand is name / pedestal / placard / one-line "now showing" caption. */
article ul.entrance {
list-style: none;
margin: 2.5rem 0 1rem;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 2.8rem 1.5rem;
}
.stand { text-align: center; min-width: 0; }
/* The name sits on the cap (negative margin; the svg below is its seat). */
.stand .name { font-size: 1.35rem; margin: 0 0 -7px; position: relative; }
.stand .name a { text-decoration: none; }
.stand .name a:hover { text-decoration: underline; }
/* Each stand's pedestal is its own inline drawing (seeded by the repo name;
see standSVG): currentColor strokes follow the ink through both schemes. */
.stand .pedestal { height: 84px; color: var(--ink); }
.stand .pedestal svg { display: block; margin: 0 auto; }
.stand .placard {
font-size: .8rem;
letter-spacing: .12em;
color: var(--ink-soft);
margin: .7rem 0 .15rem;
}
.stand .showing {
font-size: .78rem;
font-style: italic;
color: var(--ink-soft);
margin: 0;
white-space: nowrap;
overflow: hidden;
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;
font-style: italic;
color: var(--ink-soft);
margin: 0 0 .75rem;
}
/* The vitals card: the clone line and the language meter in one rough box,
placed before the README so the useful impressions land before the exhibit steers.
Meter colors are linguist's conventions, used as swatches only; names and
percentages stay ink. The faint inset ring keeps pale swatches from dissolving
into the paper (or the dark ground). */
article .vitals {
display: block;
width: fit-content;
max-width: 100%;
box-sizing: border-box;
margin: 0 auto 1.8rem;
padding: .4rem 1.2rem .5rem;
}
article .vitals p.clone { margin: .2rem 0; }
article .vitals .langmeter { display: block; margin: .6rem 0 0; }
/* The clone line and branch list sit centered, like gallery cards. */
article p.clone, article #branches { text-align: center; margin-left: auto; margin-right: auto; }
.langmeter .langbar {
display: flex;
gap: 3px;
width: 28rem;
max-width: 100%;
height: 12px;
}
.langmeter .langbar .seg {
flex-basis: 0;
min-width: 2px;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ink) 25%, transparent);
}
.langmeter .langchips {
margin: .6rem 0 0;
font-size: .78rem;
letter-spacing: .08em;
color: var(--ink-soft);
text-align: center;
}
.langmeter .chip { white-space: nowrap; }
.langmeter .chip + .chip { margin-left: .9rem; }
/* The swatch is shared: the meter's legend and the entrance placards both wear it. */
.swatch {
display: inline-block;
width: 10px;
height: 10px;
margin-right: .35em;
border-radius: 2px;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ink) 25%, transparent);
}
.langmeter .seg.other, .langmeter .swatch.other { background: var(--ink-soft); }
/* 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;
overflow-wrap: anywhere;
}
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. The floor's gather
starts high and sparse, so a short page's run-up reads as approach, not absence. */
footer.ground { margin-top: 2.5rem; }
footer.ground .floor {
height: 230px;
background-position: bottom center;
background-size: 1800px 230px;
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;
}
/* The kicker: which project this sheet belongs to, re-asserted above the heading
on pages deep in a repo, in the provenance register; the name links home. */
article .kicker {
margin: 0 0 -.6rem;
font-size: .78rem;
letter-spacing: .16em;
color: var(--ink-soft);
}
article .kicker a { color: inherit; }
/* The ledger: provenance rows on the log page. Subjects are prose and wrap
freely; the attribution line stays soft and small. Links wear ink, not accent:
a hundred accent rows would shout; hover answers the "is it live" question. */
article .provenance {
margin: -.4rem 0 1.6rem;
font-size: .78rem;
letter-spacing: .16em;
color: var(--ink-soft);
}
article ul.ledger { list-style: none; padding: 0; }
ul.ledger li { padding: .5rem 0 .8rem; }
ul.ledger .subject { margin: 0; max-width: 44rem; }
ul.ledger .subject a { color: var(--ink); text-decoration: none; }
ul.ledger .subject a:hover { color: var(--accent); text-decoration: underline; }
ul.ledger .attrib {
margin: .3rem 0 0;
font-size: .78rem;
color: var(--ink-soft);
}
a.sha {
font-family: monospace;
color: var(--ink);
text-decoration: none;
display: inline-block;
padding: 0 .35rem;
margin-right: .35rem;
}
a.sha:hover { color: var(--accent); }
/* The catalogue: listings keep their tabular bones; rows separate with soft
hand-rules (set in sketchCSS) and directory links carry the drawer-tab mark. */
/* The catalogue sits center-room with a comfortable minimum width:
pinned to the left edge it read as a sliver in an empty hall at desktop widths.
min() keeps narrow screens unharmed. */
article table.tree {
border-collapse: collapse;
margin: 0 auto;
min-width: min(36rem, 100%);
}
article table.tree td, article table.tree th { border: none; padding: .3rem .6rem; }
article table.tree tbody tr:hover { background-color: var(--well); }
article td.dir > a {
background-repeat: no-repeat;
background-position: left center;
background-size: 24px 18px;
padding-left: 30px;
}
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 { text-decoration: none; }
article table.tree 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; }
/* Headings and inline code may carry paths, shas, and clone commands: long tokens
break rather than force the page wide. (Wells scroll; this is for prose flow.) */
article h1, article code { overflow-wrap: anywhere; }
/* Phone widths: the arc flattens to a tappable row, insets yield to content,
and the exhibit mat thins. Buttons stay still within this layout; the
breakpoint is the only place they are allowed to differ. */
@media (max-width: 560px) {
/* The sun still rises on phones: buttons shrink and the precomputed drops scale
down with them (same ratio as the size change), but the curve stays.
Width check: 4 dawn buttons at 64px + 3 gaps ~= 296px, fits 390 without wrap. */
nav.arc { gap: .8rem; padding: 1.2rem 0 calc(.6rem + var(--arc-drop, 0px) * .72); }
nav.arc a { transform: translateY(calc(var(--dy, 0px) * .72)); width: 64px; height: 64px; font-size: .72rem; }
nav.arc.corner { padding: .8rem 0 calc(.4rem + var(--arc-drop, 0px) * .72); }
nav.arc.corner a { width: 48px; height: 48px; }
article > * { margin-left: 0; }
article pre { margin-left: 0; }
.exhibit { padding: .9rem 1rem; }
}
`
// 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; }
.emptyroom .pedestal { 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;
}
/* Each ledger row sits under its own light hand-rule; the chip outline around a
sha is the small sibling of the frames, so it reads as a button, not a well. */
ul.ledger li {
background-image: %s;
background-position: bottom left;
background-repeat: repeat-x;
background-size: auto 9px;
}
/* Catalogue rows separate with the rule's soft sibling; the tab mark rides the link. */
article table.tree tbody tr {
background-image: %s;
background-position: bottom left;
background-repeat: repeat-x;
background-size: auto 8px;
}
article td.dir > a { background-image: %s; }
a.sha {
border: 7px solid transparent;
border-image: %s 8 / 7px round;
}
/* 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("plinth"), u("rule"), u("rule"), u("rulesoft"), u("tab"), u("chip"), 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 computes the arc's reserved height and writes the full page.
func (s *server) renderPage(w http.ResponseWriter, pd pageData) {
for _, b := range pd.Arc {
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)
fmt.Fprintf(w, "\n<!-- template error: %v -->", err)
}
}
chrome.go · 20.3 KiB · 245d322a2e