Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }