sketch.go
package main
import (
"fmt"
"hash/fnv"
"math"
"strings"
"vitrine/display"
)
// This file generates the sketch assets: small SVGs of hand-wobbled strokes, boxes,
// circles, and hatching, which the stylesheet uses as backgrounds and border-images.
// Everything is a pure function of (theme seed, purpose), so the bytes are stable,
// snapshot-testable, and safe to cache hard.
//
// SVG served as a CSS background-image cannot see the page's CSS variables or media
// queries reliably, so every asset is generated once per color scheme (light/dark)
// and the stylesheet picks the right one in a media query.
// sketchRand is a tiny owned PRNG: splitmix64 over an fnv1a-hashed string seed.
// Owning the generator (rather than using a stdlib rand) keeps the wobble byte-stable
// across Go releases; snapshots and long-lived caches pin these bytes.
type sketchRand struct{ state uint64 }
func newSketchRand(seed string) *sketchRand {
h := fnv.New64a()
h.Write([]byte(seed))
return &sketchRand{state: h.Sum64()}
}
func (r *sketchRand) next() uint64 {
r.state += 0x9e3779b97f4a7c15
z := r.state
z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9
z = (z ^ (z >> 27)) * 0x94d049bb133111eb
return z ^ (z >> 31)
}
// rangef returns a uniform float64 in [lo, hi).
func (r *sketchRand) rangef(lo, hi float64) float64 {
return lo + (hi-lo)*(float64(r.next()>>11)/(1<<53))
}
type pt struct{ x, y float64 }
// wobblePath emits a run of cubic bezier segments through the given anchor points,
// with control points drifting perpendicular to each chord by up to amp.
// Anchors are used exactly as given: callers place (and jitter) them,
// which lets tiling assets pin zero displacement at tile seams.
func wobblePath(sb *strings.Builder, rng *sketchRand, pts []pt, amp float64) {
fmt.Fprintf(sb, "M%.1f %.1f", pts[0].x, pts[0].y)
for i := 1; i < len(pts); i++ {
a, b := pts[i-1], pts[i]
dx, dy := b.x-a.x, b.y-a.y
ln := math.Hypot(dx, dy)
if ln == 0 {
ln = 1
}
px, py := -dy/ln, dx/ln
j1, j2 := rng.rangef(-amp, amp), rng.rangef(-amp, amp)
fmt.Fprintf(sb, "C%.1f %.1f %.1f %.1f %.1f %.1f",
a.x+dx/3+px*j1, a.y+dy/3+py*j1,
a.x+2*dx/3+px*j2, a.y+2*dy/3+py*j2,
b.x, b.y)
}
}
// roughLine emits one hand-drawn stroke: endpoints jittered and overshooting slightly
// past where a ruler would stop (the sketch tell), body wobbling by up to amp.
func roughLine(sb *strings.Builder, rng *sketchRand, a, b pt, amp, over float64) {
dx, dy := b.x-a.x, b.y-a.y
ln := math.Hypot(dx, dy)
if ln == 0 {
return
}
ux, uy := dx/ln, dy/ln
oa, ob := rng.rangef(0, over), rng.rangef(0, over)
ej := amp / 2
a = pt{a.x - ux*oa + rng.rangef(-ej, ej), a.y - uy*oa + rng.rangef(-ej, ej)}
b = pt{b.x + ux*ob + rng.rangef(-ej, ej), b.y + uy*ob + rng.rangef(-ej, ej)}
wobblePath(sb, rng, []pt{a, b}, amp)
}
// roughBox emits four rough strokes around a rectangle, overshooting at the corners.
func roughBox(sb *strings.Builder, rng *sketchRand, x, y, w, h, amp, over float64) {
roughLine(sb, rng, pt{x, y}, pt{x + w, y}, amp, over)
roughLine(sb, rng, pt{x + w, y}, pt{x + w, y + h}, amp, over)
roughLine(sb, rng, pt{x + w, y + h}, pt{x, y + h}, amp, over)
roughLine(sb, rng, pt{x, y + h}, pt{x, y}, amp, over)
}
// roughCircle emits a hand-drawn circle: four quarter arcs between anchors at jittered
// angles and radii, closing with a small miss at the seam, like a pen lifting.
func roughCircle(sb *strings.Builder, rng *sketchRand, c pt, r, amp float64) {
const k = 0.5523 // the standard cubic-arc handle ratio for a quarter circle
start := rng.rangef(0, 2*math.Pi)
var anchors [5]pt
var angles [5]float64
var radii [5]float64
for i := 0; i < 4; i++ {
angles[i] = start + float64(i)*math.Pi/2 + rng.rangef(-0.09, 0.09)
radii[i] = r + rng.rangef(-amp, amp)
anchors[i] = pt{c.x + radii[i]*math.Cos(angles[i]), c.y + radii[i]*math.Sin(angles[i])}
}
angles[4], radii[4] = angles[0], radii[0]
anchors[4] = pt{anchors[0].x + rng.rangef(-amp, amp), anchors[0].y + rng.rangef(-amp, amp)}
fmt.Fprintf(sb, "M%.1f %.1f", anchors[0].x, anchors[0].y)
for i := 0; i < 4; i++ {
h1 := k * radii[i] * rng.rangef(0.9, 1.1)
h2 := k * radii[i+1] * rng.rangef(0.9, 1.1)
fmt.Fprintf(sb, "C%.1f %.1f %.1f %.1f %.1f %.1f",
anchors[i].x-h1*math.Sin(angles[i]), anchors[i].y+h1*math.Cos(angles[i]),
anchors[i+1].x+h2*math.Sin(angles[i+1]), anchors[i+1].y-h2*math.Cos(angles[i+1]),
anchors[i+1].x, anchors[i+1].y)
}
}
// ---- the assets ----
// sketchPalette is the per-scheme ink color; each purpose picks its own opacity,
// following choosing-colors: structure gets tints of the ink, never a new hue.
type sketchPalette struct {
scheme string // "light" or "dark", the asset filename suffix
ink string
}
var sketchPalettes = []sketchPalette{
{"light", "#24292f"},
{"dark", "#e6edf3"},
}
// sketchAssets builds every sketch SVG for one theme seed, keyed by filename
// (e.g. "frame-light.svg"). Each asset seeds its own PRNG with seed+purpose,
// so assets wobble independently and adding one never reshuffles the others.
func sketchAssets(seed string) map[string][]byte {
m := map[string][]byte{}
for _, p := range sketchPalettes {
m["frame-"+p.scheme+".svg"] = frameSVG(seed, p)
m["rule-"+p.scheme+".svg"] = ruleSVG(seed, "rule", p, 0.7, 0.25)
m["rulesoft-"+p.scheme+".svg"] = ruleSVG(seed, "rulesoft", p, 0.4, 0.15)
m["flank-"+p.scheme+".svg"] = flankSVG(seed, p)
m["ground-"+p.scheme+".svg"] = groundSVG(seed, p)
m["plinth-"+p.scheme+".svg"] = plinthSVG(seed, p)
m["plinthgrand-"+p.scheme+".svg"] = plinthGrandSVG(seed, p)
m["chip-"+p.scheme+".svg"] = chipSVG(seed, p)
m["tab-"+p.scheme+".svg"] = tabSVG(seed, p)
for _, v := range []string{"a", "b", "c"} {
m["circle-"+v+"-"+p.scheme+".svg"] = circleSVG(seed+"/"+v, p)
}
}
m["favicon.svg"] = faviconSVG(seed)
return m
}
// svgOpen writes the opening tag, with explicit width/height: an SVG without intrinsic
// dimensions misrenders as a border-image (the slice offsets have no size to resolve against).
func svgOpen(sb *strings.Builder, w, h int) {
fmt.Fprintf(sb, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`, w, h, w, h)
sb.WriteString("\n")
}
// svgPath writes one stroked path element holding all the segments accumulated in d.
func svgPath(sb *strings.Builder, d string, ink string, width, opacity float64) {
fmt.Fprintf(sb, `<path d="%s" fill="none" stroke="%s" stroke-width="%.1f" stroke-opacity="%.2f" stroke-linecap="round"/>`, d, ink, width, opacity)
sb.WriteString("\n")
}
// frameSVG is the border-image tile for rough-ruled frames: a 96x96 tile, sliced at 16,
// with the lines drawn at inset 8 -- so a 13px border renders the slice close to 1:1
// and the stroke survives at full weight. Displacement is pinned to zero where the
// edge lines cross the slice boundaries, so border-image-repeat: round tiles the
// middles without visible seams; the corner regions wobble freely and the lines
// overshoot past each crossing.
func frameSVG(seed string, p sketchPalette) []byte {
rng := newSketchRand(seed + "/frame/" + p.scheme)
var sb strings.Builder
svgOpen(&sb, 96, 96)
edge := func(d *strings.Builder, amp float64) {
j := func() float64 { return rng.rangef(-amp, amp) }
// Each edge runs corner to corner at inset 8, crossing the perpendicular
// edges near the corners; anchors at 16 and 80 sit exactly on the line.
wobblePath(d, rng, []pt{{3.5 + j(), 8 + j()}, {16, 8}, {48 + j(), 8 + j()}, {80, 8}, {92.5 + j(), 8 + j()}}, amp)
wobblePath(d, rng, []pt{{88 + j(), 3.5 + j()}, {88, 16}, {88 + j(), 48 + j()}, {88, 80}, {88 + j(), 92.5 + j()}}, amp)
wobblePath(d, rng, []pt{{92.5 + j(), 88 + j()}, {80, 88}, {48 + j(), 88 + j()}, {16, 88}, {3.5 + j(), 88 + j()}}, amp)
wobblePath(d, rng, []pt{{8 + j(), 92.5 + j()}, {8, 80}, {8 + j(), 48 + j()}, {8, 16}, {8 + j(), 3.5 + j()}}, amp)
}
var d1, d2 strings.Builder
edge(&d1, 1.3)
edge(&d2, 1.8)
svgPath(&sb, d1.String(), p.ink, 1.8, 0.75)
svgPath(&sb, d2.String(), p.ink, 1.4, 0.28) // the ghost pass: a second, fainter hand
sb.WriteString("</svg>\n")
return []byte(sb.String())
}
// ruleSVG is a wide, thin tile holding one hand-ruled horizontal line, for nav and
// heading underlines (full-strength opacities) and row separators (the soft ones).
// Displacement is zero at both tile ends so repeat tiles cleanly.
func ruleSVG(seed, purpose string, p sketchPalette, opacity, ghostOpacity float64) []byte {
rng := newSketchRand(seed + "/" + purpose + "/" + p.scheme)
var sb strings.Builder
svgOpen(&sb, 480, 12)
anchorRow := func(amp float64) []pt {
pts := []pt{{0, 6}}
for x := 96.0; x < 480; x += 96 {
pts = append(pts, pt{x + rng.rangef(-8, 8), 6 + rng.rangef(-amp, amp)})
}
return append(pts, pt{480, 6})
}
var d1, d2 strings.Builder
wobblePath(&d1, rng, anchorRow(1.1), 1.1)
wobblePath(&d2, rng, anchorRow(1.5), 1.5)
svgPath(&sb, d1.String(), p.ink, 1.8, opacity)
svgPath(&sb, d2.String(), p.ink, 1.3, ghostOpacity)
sb.WriteString("</svg>\n")
return []byte(sb.String())
}
// circleSVG is one round-button circle: a 96x96 tile, drawn twice (a crisp pass and a
// fainter ghost) so buttons look sketched rather than stamped. Three variants exist
// (seed carries the variant) so hover and active states can swap drawings without JS.
func circleSVG(seed string, p sketchPalette) []byte {
rng := newSketchRand(seed + "/circle/" + p.scheme)
var sb strings.Builder
svgOpen(&sb, 96, 96)
var d1, d2 strings.Builder
roughCircle(&d1, rng, pt{48, 48}, 42, 2.0)
roughCircle(&d2, rng, pt{48, 48}, 42, 2.8)
svgPath(&sb, d1.String(), p.ink, 2.4, 0.85)
svgPath(&sb, d2.String(), p.ink, 1.6, 0.3)
sb.WriteString("</svg>\n")
return []byte(sb.String())
}
// flankSVG is the margin cross-hatching: a 240x720 tile for background-repeat: repeat-y,
// densest at x=0 (the viewport edge), fading to nothing before the content column.
// Strokes near the tile's top or bottom are drawn again one tile-height away,
// so the vertical repeat has no visible seam.
func flankSVG(seed string, p sketchPalette) []byte {
const W, H = 240.0, 720.0
rng := newSketchRand(seed + "/flank/" + p.scheme)
var sb strings.Builder
svgOpen(&sb, int(W), int(H))
var d strings.Builder
hatch := func(n int, angle, spread float64, maxX float64) {
for i := 0; i < n; i++ {
// Cube-root sampling gives density falling off quadratically toward maxX.
u := rng.rangef(0, 1)
cx := maxX * (1 - math.Cbrt(u))
cy := rng.rangef(0, H)
ln := rng.rangef(9, 17) // half-length
a := angle + rng.rangef(-spread, spread)
dx, dy := ln*math.Cos(a), ln*math.Sin(a)
stroke := func(y float64) {
roughLine(&d, rng, pt{cx - dx, y - dy}, pt{cx + dx, y + dy}, 0.9, 1.5)
}
stroke(cy)
if cy < 40 {
stroke(cy + H)
}
if cy > H-40 {
stroke(cy - H)
}
}
}
hatch(150, -0.96, 0.14, W) // the main pencil direction, steep backslash
hatch(70, 1.05, 0.14, W*0.6) // the crossing direction, only in the denser band
svgPath(&sb, d.String(), p.ink, 1.0, 0.3)
sb.WriteString("</svg>\n")
return []byte(sb.String())
}
// groundSVG is the footer floor: hatching gathering downward into a hand-ruled baseline,
// with a small pedestal glyph standing on it at center. Positioned bottom-center as a
// single non-repeating background; density converging is the arrival signal.
// The approach band is tall and starts sparse, so on short pages the run-up to the
// floor reads as intent rather than emptiness.
func groundSVG(seed string, p sketchPalette) []byte {
const W, H = 1800.0, 230.0
const baseY = 190.0
rng := newSketchRand(seed + "/ground/" + p.scheme)
var sb strings.Builder
svgOpen(&sb, int(W), int(H))
// The floor hatching: strokes gathering into the baseline. Height above the line
// is cubed-uniform, so density rises sharply on approach; strokes also flatten and
// shorten as they near the line, and the near band gets its own darker pass so the
// convergence is tonal as well as spatial. A soft horizontal envelope fades the ends.
var dNear, dFar strings.Builder
for i := 0; i < 520; i++ {
cx := rng.rangef(30, W-30)
env := math.Sin(math.Pi * cx / W)
if rng.rangef(0, 1) > env*env {
continue
}
u := rng.rangef(0, 1)
cy := baseY - 2 - 160*u*u*u
ln := rng.rangef(7, 12+16*u)
a := -(0.05 + 0.5*u) + rng.rangef(-0.08, 0.08)
dx, dy := ln*math.Cos(a), ln*math.Sin(a)
d := &dNear
if baseY-cy > 20 {
d = &dFar
}
roughLine(d, rng, pt{cx - dx, cy - dy}, pt{cx + dx, cy + dy}, 0.9, 1.5)
}
svgPath(&sb, dNear.String(), p.ink, 1.0, 0.42)
svgPath(&sb, dFar.String(), p.ink, 1.0, 0.22)
// The baseline: one long rule, drawn twice.
var db strings.Builder
baseRow := func(amp float64) []pt {
pts := []pt{{30 + rng.rangef(-6, 6), baseY + rng.rangef(-amp, amp)}}
for x := 300.0; x < W-200; x += 300 {
pts = append(pts, pt{x + rng.rangef(-30, 30), baseY + rng.rangef(-amp, amp)})
}
return append(pts, pt{W - 30 + rng.rangef(-6, 6), baseY + rng.rangef(-amp, amp)})
}
wobblePath(&db, rng, baseRow(1.6), 1.6)
wobblePath(&db, rng, baseRow(2.2), 2.2)
svgPath(&sb, db.String(), p.ink, 1.8, 0.65)
// The pedestal: cap, die, plinth, standing centered on the baseline.
var dp strings.Builder
roughBox(&dp, rng, 858, 128, 84, 13, 1.0, 2.5)
roughBox(&dp, rng, 870, 141, 60, 34, 1.0, 2.5)
roughBox(&dp, rng, 850, 175, 100, 15, 1.0, 2.5)
svgPath(&sb, dp.String(), p.ink, 1.6, 0.75)
sb.WriteString("</svg>\n")
return []byte(sb.String())
}
// plinthSVG is the entrance's pedestal: the ground glyph's cap/die/plinth family,
// redrawn at exhibit-stand size, with the usual crisp and ghost passes.
// Repos stand on these at the index; the footer's empty pedestal echoes them.
func plinthSVG(seed string, p sketchPalette) []byte {
rng := newSketchRand(seed + "/plinth/" + p.scheme)
var sb strings.Builder
svgOpen(&sb, 140, 84)
boxes := func(d *strings.Builder, amp, over float64) {
roughBox(d, rng, 20, 10, 100, 14, amp, over)
roughBox(d, rng, 34, 24, 72, 40, amp, over)
roughBox(d, rng, 10, 64, 120, 16, amp, over)
}
var d1, d2 strings.Builder
boxes(&d1, 1.0, 2.5)
boxes(&d2, 1.5, 3.0)
svgPath(&sb, d1.String(), p.ink, 1.6, 0.75)
svgPath(&sb, d2.String(), p.ink, 1.2, 0.28)
sb.WriteString("</svg>\n")
return []byte(sb.String())
}
// faviconSVG is the tab's identity mark: the pedestal glyph in the theme hue.
// One asset, not per-scheme: a favicon renders on tab chrome whose color socle
// cannot know, so instead of the accent's paper-tuned per-scheme lightnesses it
// wears the same hue at a middle lightness that stays visible on both.
// Strokes are proportionally bold: at tab size the usual hairlines would vanish.
func faviconSVG(seed string) []byte {
ink := fmt.Sprintf("hsl(%d 70%% 50%%)", display.ThemeHue(seed))
rng := newSketchRand(seed + "/favicon")
var sb strings.Builder
svgOpen(&sb, 64, 64)
boxes := func(d *strings.Builder, amp, over float64) {
roughBox(d, rng, 12, 8, 40, 9, amp, over)
roughBox(d, rng, 20, 17, 24, 29, amp, over)
roughBox(d, rng, 6, 46, 52, 10, amp, over)
}
var d1, d2 strings.Builder
boxes(&d1, 1.0, 2.0)
boxes(&d2, 1.5, 2.5)
svgPath(&sb, d1.String(), ink, 5.0, 0.92)
svgPath(&sb, d2.String(), ink, 3.0, 0.4)
sb.WriteString("</svg>\n")
return []byte(sb.String())
}
// plinthGrandSVG is the marquee's pedestal: the same cap/die/plinth family at the
// entrance-masthead size, drawn natively rather than scaled so the stroke weight
// stays in step with its siblings. The site's own name stands on this one.
func plinthGrandSVG(seed string, p sketchPalette) []byte {
rng := newSketchRand(seed + "/plinthgrand/" + p.scheme)
var sb strings.Builder
svgOpen(&sb, 220, 132)
boxes := func(d *strings.Builder, amp, over float64) {
roughBox(d, rng, 31, 16, 157, 22, amp, over)
roughBox(d, rng, 53, 38, 113, 63, amp, over)
roughBox(d, rng, 16, 101, 188, 25, amp, over)
}
var d1, d2 strings.Builder
boxes(&d1, 1.2, 3.0)
boxes(&d2, 1.8, 3.6)
svgPath(&sb, d1.String(), p.ink, 1.8, 0.75)
svgPath(&sb, d2.String(), p.ink, 1.3, 0.28)
sb.WriteString("</svg>\n")
return []byte(sb.String())
}
// chipSVG is the frame's small sibling: a border-image tile for inline chips
// (the ledger's sha buttons). A 48x48 tile sliced at 8 with lines at inset 4,
// so a border a hair under the slice renders the stroke near 1:1; displacement
// pins to zero at the slice crossings, same scheme as frameSVG, so repeat: round
// tiles the middles cleanly at whatever width the chip's text wants.
func chipSVG(seed string, p sketchPalette) []byte {
rng := newSketchRand(seed + "/chip/" + p.scheme)
var sb strings.Builder
svgOpen(&sb, 48, 48)
edge := func(d *strings.Builder, amp float64) {
j := func() float64 { return rng.rangef(-amp, amp) }
wobblePath(d, rng, []pt{{1.8 + j(), 4 + j()}, {8, 4}, {24 + j(), 4 + j()}, {40, 4}, {46.2 + j(), 4 + j()}}, amp)
wobblePath(d, rng, []pt{{44 + j(), 1.8 + j()}, {44, 8}, {44 + j(), 24 + j()}, {44, 40}, {44 + j(), 46.2 + j()}}, amp)
wobblePath(d, rng, []pt{{46.2 + j(), 44 + j()}, {40, 44}, {24 + j(), 44 + j()}, {8, 44}, {1.8 + j(), 44 + j()}}, amp)
wobblePath(d, rng, []pt{{4 + j(), 46.2 + j()}, {4, 40}, {4 + j(), 24 + j()}, {4, 8}, {4 + j(), 1.8 + j()}}, amp)
}
var d1, d2 strings.Builder
edge(&d1, 0.8)
edge(&d2, 1.1)
svgPath(&sb, d1.String(), p.ink, 1.2, 0.75)
svgPath(&sb, d2.String(), p.ink, 0.9, 0.28)
sb.WriteString("</svg>\n")
return []byte(sb.String())
}
// tabSVG is the catalogue's drawer-tab mark: a small folder silhouette standing
// ahead of directory names in tree listings, wordless and self-explanatory.
func tabSVG(seed string, p sketchPalette) []byte {
rng := newSketchRand(seed + "/tab/" + p.scheme)
var sb strings.Builder
svgOpen(&sb, 24, 18)
outline := func(d *strings.Builder, amp, over float64) {
roughLine(d, rng, pt{2, 15}, pt{2, 4}, amp, over)
roughLine(d, rng, pt{2, 4}, pt{9, 4}, amp, over)
roughLine(d, rng, pt{9, 4}, pt{11.5, 7}, amp, over)
roughLine(d, rng, pt{11.5, 7}, pt{22, 7}, amp, over)
roughLine(d, rng, pt{22, 7}, pt{22, 15}, amp, over)
roughLine(d, rng, pt{22, 15}, pt{2, 15}, amp, over)
}
var d1, d2 strings.Builder
outline(&d1, 0.6, 1.2)
outline(&d2, 0.9, 1.5)
svgPath(&sb, d1.String(), p.ink, 1.3, 0.7)
svgPath(&sb, d2.String(), p.ink, 1.0, 0.26)
sb.WriteString("</svg>\n")
return []byte(sb.String())
}
sketch.go · 17.9 KiB · 6b3477bda1