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
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">
</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}}{{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 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; }
.stand .name { font-size: 1.35rem; margin: 0; }
.stand .name a { text-decoration: none; }
.stand .name a:hover { text-decoration: underline; }
.stand .pedestal {
height: 84px;
background-position: top center;
background-size: 140px 84px;
background-repeat: no-repeat;
}
.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 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 language meter: composition by bytes at the shown commit, in its rough box
between the plaque and the clone line. 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 .langmeter {
display: block;
width: fit-content;
max-width: 100%;
box-sizing: border-box;
margin: 0 auto 1.4rem;
}
.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 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. */
article table.tree { border-collapse: collapse; }
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; }
.stand .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 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 · 17.8 KiB · f748a0a942