vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Draw avatars with DiceBear instead of by hand
97f77001eed76cc287aeba532c4d670488121a18 · Keenan Nemetz · 8d ago · parent ca098f62
Draw avatars with DiceBear instead of by hand The marks this package drew itself were a design I kept losing arguments with. Avatars are now DiceBear's Shapes style, which is what the request asked for. DiceBear is JavaScript and Vole is a Go binary, so the split is: the server renders the account's initial on its Everforest ground, and an embedded bundle replaces it with the mark when the page runs. The initial is what a browser without scripts keeps, so nothing depends on the bundle arriving; the seed is the account name, so the same person draws the same mark everywhere; and the ground is read from the element, so vole.css remains the only palette. The bundle is core 9.3.1 plus Shapes 9.4.2 (MIT code, CC0 design), built with esbuild and committed like the other vendored scripts: 18KB, 5.5KB gzipped. Its source is scripts/avatars-entry.js, and the licence files beside it are inventoried in NOTICE, so the verify workflow's licence check keeps passing. No request leaves the instance and no avatar is stored. Two details worth naming. Each mark gets a unique clip-mask id, because SVG ids are document-wide and a directory page holds fifty of them. And the RDF metadata block DiceBear embeds is stripped: it is identical for every avatar and its licence is in NOTICE, so repeating it in the DOM would be weight without information.
server/web/avatar.go +39−100
| @@ -1,131 +1,70 @@ | |||
| 1 | 1 | package web | |
| 2 | 2 | ||
| 3 | // A name's avatar: an abstract mark derived from the name. | ||
| 3 | // A name's avatar. | ||
| 4 | 4 | // | |
| 5 | // A forge's avatars do not need to look like anything. What they need is to be | ||
| 6 | // stable, quiet, and different from the next person's at 17px. So each account | ||
| 7 | // gets a single curled stroke — a burrow turned in on itself, or a fern frond, or | ||
| 8 | // a shell; whichever you see is up to you, and the picture never names it. The | ||
| 9 | // whole of Vole's vocabulary is here: one cream line on an earthy ground, the | ||
| 10 | // same stroke width and round caps everywhere, and nothing literal. | ||
| 5 | // The mark itself is drawn in the browser by DiceBear from a seed, so this file | ||
| 6 | // decides only the ground and the fallback. Two things follow from that split, | ||
| 7 | // and both are deliberate: | ||
| 11 | 8 | // | |
| 12 | // Everything is derived from the name and nothing else. The same account keeps | ||
| 13 | // the same mark on every page and between visits, and the instance stores no | ||
| 14 | // image, accepts no upload, and calls no avatar service. Two names that happen to | ||
| 15 | // draw alike simply share a mark: this is decoration, never a credential and | ||
| 16 | // never a way to tell one account from another. | ||
| 9 | // - The server renders the account's initial on one of seven Everforest grounds, | ||
| 10 | // exactly as every avatar looked before the marks existed. A browser without | ||
| 11 | // scripts, or one that has not run the bundle yet, shows a letter rather than | ||
| 12 | // an empty circle. The avatar is decoration; nothing about the page depends on | ||
| 13 | // it arriving. | ||
| 14 | // - The seed is the name, never an index or a session. The same account draws | ||
| 15 | // the same mark on every page and between visits, and the instance stores no | ||
| 16 | // image, accepts no upload, and calls no avatar service. The bundle is | ||
| 17 | // embedded in the binary like every other asset, so choosing an avatar makes | ||
| 18 | // no request anywhere. | ||
| 17 | 19 | // | |
| 18 | // How it is drawn: | ||
| 19 | // | ||
| 20 | // - The ground is one of seven colours from the Everforest palette Vole is | ||
| 21 | // themed on, pulled toward the page so a row of marks reads at a glance. | ||
| 22 | // - The stroke is the palette's own cream. There is only one mark, so it can | ||
| 23 | // afford the contrast. | ||
| 24 | // - How far the curl winds (a hook up to two turns), its tightness, direction, | ||
| 25 | // rotation, thickness and position are the variations. | ||
| 26 | // | ||
| 27 | // Colours live in the stylesheet rather than in the markup, so the palette stays a | ||
| 28 | // theme decision and the SVG carries only geometry. The name is never | ||
| 29 | // interpolated into the markup; a test holds that. | ||
| 20 | // The tone is derived here rather than in the sheet so a letter keeps its ground | ||
| 21 | // even when the stylesheet has not loaded; vole.css names the same seven colours. | ||
| 22 | // The mark is decoration, not an identifier: a name is what identifies an account. | ||
| 30 | 23 | ||
| 31 | 24 | import ( | |
| 32 | 25 | "crypto/sha256" | |
| 33 | "fmt" | ||
| 34 | "html/template" | ||
| 35 | "math" | ||
| 36 | 26 | "strings" | |
| 37 | 27 | ) | |
| 38 | 28 | ||
| 39 | // avatarSegments is how many points the curl is drawn with. At 40 units for a | ||
| 40 | // 17px avatar, a segment spans at most about a tenth of a pixel of curve, which | ||
| 41 | // is below what a screen can show; more points only make the markup larger. | ||
| 42 | const avatarSegments = 32 | ||
| 43 | |||
| 44 | // avatarTraits are the mark's variable parts. Every field is an index into a | ||
| 45 | // small range, so a name maps to them without any other input. | ||
| 46 | type avatarTraits struct { | ||
| 47 | Turns int // 0..3, a hook up to two turns | ||
| 48 | Inner int // 0..2, where the curl starts from the centre | ||
| 49 | Outer int // 0..5, how far out it unwinds | ||
| 50 | Rotation int // 0..7, in 45-degree steps | ||
| 51 | Direction int // 0 clockwise, 1 anticlockwise | ||
| 52 | Width int // 0..2, stroke thickness | ||
| 53 | OffsetX int // -3..3, tenths of a unit from the centre | ||
| 54 | OffsetY int // -3..3 | ||
| 55 | } | ||
| 56 | |||
| 57 | // avatarView is one portrait ready to render. Href is empty for a name that is | ||
| 58 | // not an account; the template then draws a span instead of a link. | ||
| 29 | // avatarView is one avatar ready to render. Href is empty for a name that is not | ||
| 30 | // an account; the template then draws a span instead of a link. | ||
| 59 | 31 | type avatarView struct { | |
| 32 | // Name is the seed, rendered as an attribute so the browser can draw the mark. | ||
| 33 | Name string | ||
| 60 | 34 | Tone int | |
| 61 | Href string | ||
| 62 | SVG template.HTML | ||
| 35 | // Initial is the fallback letter, shown until the mark is drawn. | ||
| 36 | Initial string | ||
| 37 | Href string | ||
| 63 | 38 | } | |
| 64 | 39 | ||
| 65 | // avatar is the mark for a name, on its own. | ||
| 40 | // avatar is the avatar for a name, on its own. | ||
| 66 | 41 | func (w *Web) avatar(name string) avatarView { return portrait(name) } | |
| 67 | 42 | ||
| 68 | // avatarUser is the mark for an account, linked to the profile it belongs to when | ||
| 69 | // the name resolves to one. | ||
| 43 | // avatarUser is the avatar for an account, linked to the profile it belongs to | ||
| 44 | // when the name resolves to one. | ||
| 70 | 45 | func (w *Web) avatarUser(ref userRef) avatarView { | |
| 71 | 46 | v := portrait(ref.Name) | |
| 72 | 47 | v.Href = ref.Href | |
| 73 | 48 | return v | |
| 74 | 49 | } | |
| 75 | 50 | ||
| 76 | // portrait derives the mark from the name. SHA-256 rather than a cheap hash | ||
| 77 | // because its bytes are read as independent traits: with a polynomial hash the | ||
| 78 | // low bits of two similar names move together, and half a team would draw the | ||
| 79 | // same curl. It also means the picture can change only when this code changes, | ||
| 80 | // never because a store or a rebuild moved. | ||
| 51 | // portrait derives the ground and the fallback letter from the name. SHA-256 | ||
| 52 | // rather than a cheap hash so two similar names do not land on the same tone by | ||
| 53 | // construction; the mark's own variation comes from the bundle's seeded PRNG. | ||
| 81 | 54 | func portrait(name string) avatarView { | |
| 82 | 55 | sum := sha256.Sum256([]byte(name)) | |
| 83 | 56 | return avatarView{ | |
| 84 | Tone: int(sum[8]%7) + 1, | ||
| 85 | SVG: buildAvatar(avatarTraits{ | ||
| 86 | Turns: int(sum[9] % 4), | ||
| 87 | Inner: int(sum[10] % 3), | ||
| 88 | Outer: int(sum[11] % 6), | ||
| 89 | Rotation: int(sum[12] % 8), | ||
| 90 | Direction: int(sum[13] % 2), | ||
| 91 | Width: int(sum[14] % 3), | ||
| 92 | OffsetX: int(sum[15]%7) - 3, | ||
| 93 | OffsetY: int(sum[16]%7) - 3, | ||
| 94 | }), | ||
| 57 | Name: name, | ||
| 58 | Tone: int(sum[8]%7) + 1, | ||
| 59 | Initial: initial(name), | ||
| 95 | 60 | } | |
| 96 | 61 | } | |
| 97 | 62 | ||
| 98 | // buildAvatar renders the stroke. Every value is a number this package chose, | ||
| 99 | // never the name, so the markup cannot carry anything a user wrote and is safe to | ||
| 100 | // hand to the template as HTML. | ||
| 101 | func buildAvatar(t avatarTraits) template.HTML { | ||
| 102 | turns := 0.85 + float64(t.Turns)*0.42 | ||
| 103 | inner := 1.0 + float64(t.Inner)*0.5 | ||
| 104 | outer := 8.5 + float64(t.Outer)*1.2 | ||
| 105 | rotate := float64(t.Rotation) * math.Pi / 4 | ||
| 106 | direction := 1.0 | ||
| 107 | if t.Direction == 1 { | ||
| 108 | direction = -1 | ||
| 109 | } | ||
| 110 | width := 2.2 + float64(t.Width)*0.6 | ||
| 111 | cx := 20 + float64(t.OffsetX)*0.7 | ||
| 112 | cy := 20 + float64(t.OffsetY)*0.7 | ||
| 113 | |||
| 114 | var b strings.Builder | ||
| 115 | fmt.Fprintf(&b, `<svg class="vole-avatar" viewBox="0 0 40 40" aria-hidden="true" focusable="false">`) | ||
| 116 | fmt.Fprintf(&b, `<path class="av-mark" fill="none" stroke-width="%.1f" d="`, width) | ||
| 117 | for i := 0; i <= avatarSegments; i++ { | ||
| 118 | f := float64(i) / avatarSegments | ||
| 119 | angle := rotate + direction*f*turns*2*math.Pi | ||
| 120 | radius := inner + (outer-inner)*f | ||
| 121 | x := cx + math.Cos(angle)*radius | ||
| 122 | y := cy + math.Sin(angle)*radius | ||
| 123 | if i == 0 { | ||
| 124 | fmt.Fprintf(&b, "M%.1f %.1f", x, y) | ||
| 125 | continue | ||
| 126 | } | ||
| 127 | fmt.Fprintf(&b, "L%.1f %.1f", x, y) | ||
| 63 | // initial is the fallback letter. Account names are authoritative, so the first | ||
| 64 | // rune is enough; an empty name gets "?" rather than an empty circle. | ||
| 65 | func initial(name string) string { | ||
| 66 | for _, r := range strings.TrimSpace(name) { | ||
| 67 | return strings.ToUpper(string(r)) | ||
| 128 | 68 | } | |
| 129 | b.WriteString(`"/></svg>`) | ||
| 130 | return template.HTML(b.String()) | ||
| 69 | return "?" | ||
| 131 | 70 | } | |