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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Draw a vole portrait for every name

753cd703960a1f024b3e5819ae6330bb2ab1cd08 · Keenan Nemetz · 7d ago · parent a6b42433

Draw a vole portrait for every name

The wordmark is a vole's face, so a name gets one too: the profile, the
directory, comment cards, inline review notes, approvals and the
contributor list all render a small vole drawn from the account name.

The portrait is derived with SHA-256 rather than stored. The same name
draws the same ears, eyes, whiskers, fur shade and ground colour on every
page and between visits, and the instance keeps no image, accepts no
upload, and calls no avatar service. The seven grounds are the
Everforest accents pulled toward the page, the head and features use the
palette's cream and dark ground, and the picture is inline SVG: the CSP,
the single binary, and an instance with no outbound requests are all
unchanged.

The name never reaches the markup. The builder takes only integer
traits, which is what makes it safe to hand the template as HTML, and a
test holds that a hostile name cannot appear in the output.

Two shapes were tried and dropped. Separating the ears with a
ground-coloured gap, the way the wordmark does, collapses into noise
below 24px, so the ears and head are one silhouette. Cheek patches and
brow strokes read as smudges at the same size, so a blaze down the
forehead is the only marking left; the rest of the variation is colour.

server/web/avatar.go +145

@@ -0,0 +1,145 @@
1 package web
2
3 // A name's avatar: a small vole, drawn from the name.
4 //
5 // The wordmark is a vole's face, so every account gets one too. The portrait is
6 // derived from the name and nothing else, which means the same account keeps the
7 // same face on every page and between visits, and the instance stores no image,
8 // accepts no upload, and calls no avatar service. Two names that happen to draw
9 // alike simply share a face: this is decoration, never a credential and never a
10 // way to tell one account from another.
11 //
12 // How it is drawn is the whole of the design:
13 //
14 // - The ground is one of seven colours from the Everforest palette Vole is
15 // themed on, pulled toward the page so a row of faces reads at a glance.
16 // - The head is the palette's own cream, so the effect is the wordmark's pale
17 // vole at small size, and the features use its dark ground colour.
18 // - Ears, eyes, whiskers, fur shade and a forehead blaze are the variations.
19 // Ears and eyes read at 17px; the whiskers appear at the profile's 52px. It
20 // is the same picture at both sizes, not an icon that swaps for a larger one.
21 //
22 // Colours live in the stylesheet rather than in the markup, so the palette stays a
23 // theme decision and the SVG carries only geometry.
24
25 import (
26 "crypto/sha256"
27 "fmt"
28 "html/template"
29 "strings"
30 )
31
32 // avatarTraits are the face's variable parts. They are derived once from the
33 // name; every later use of the same name derives the same ones.
34 //
35 // Cheek patches and brow strokes were tried and dropped: below 24px they read as
36 // smudges rather than markings. What is left changes the silhouette (ears), the
37 // expression (eyes), the fur, and the one marking that survives shrinking, a
38 // blaze down the forehead.
39 type avatarTraits struct {
40 Ears int // 0 round, 1 tall
41 Eyes int // 0 open, 1 ringed, 2 closed
42 Whiskers int // 0 none, 1 two a side, 2 three a side
43 Fur int // 0 cream, 1 pale, 2 warm
44 Blaze bool
45 }
46
47 // avatarView is one portrait ready to render. Href is empty for a name that is
48 // not an account; the template then draws a span instead of a link.
49 type avatarView struct {
50 Tone int
51 Href string
52 SVG template.HTML
53 }
54
55 // avatar is the portrait for a name, on its own.
56 func (w *Web) avatar(name string) avatarView { return portrait(name) }
57
58 // avatarUser is the portrait for an account, linked to the profile it belongs to
59 // when the name resolves to one.
60 func (w *Web) avatarUser(ref userRef) avatarView {
61 v := portrait(ref.Name)
62 v.Href = ref.Href
63 return v
64 }
65
66 // portrait derives the face from the name. SHA-256 rather than a cheap hash
67 // because its bytes are read as independent traits: with a polynomial hash the
68 // low bits of two similar names move together, and half a team would share a
69 // marking. It also means the picture can change only when this code changes,
70 // never because a store or a rebuild moved.
71 func portrait(name string) avatarView {
72 sum := sha256.Sum256([]byte(name))
73 return avatarView{
74 Tone: int(sum[8]%7) + 1,
75 SVG: buildAvatar(avatarTraits{
76 Ears: int(sum[9] % 2),
77 Eyes: int(sum[10] % 3),
78 Whiskers: int(sum[12] % 3),
79 Fur: int(sum[13] % 3),
80 Blaze: sum[14]%2 == 0,
81 }),
82 }
83 }
84
85 // buildAvatar renders the geometry. Every value is a constant or a trait this
86 // package chose, never the name, so the markup cannot carry anything a user wrote
87 // and is safe to hand to the template as HTML.
88 func buildAvatar(t avatarTraits) template.HTML {
89 var b strings.Builder
90 b.WriteString(`<svg class="vole-avatar fur-` + fmt.Sprint(t.Fur+1) +
91 `" viewBox="0 0 40 40" aria-hidden="true" focusable="false">`)
92
93 // The ears are drawn first and the head over their lower halves, so the two
94 // read as one silhouette with bumps rather than as circles floating above a
95 // face. A ground-coloured gap between them, the way the wordmark separates
96 // them, collapses into noise at 20px.
97 earX, earY, earR := 10.2, 10.6, 4.7
98 if t.Ears == 1 {
99 earX, earY, earR = 10.8, 9.9, 4.0
100 }
101 for _, side := range []float64{-1, 1} {
102 cx := 20 + side*(20-earX)
103 fmt.Fprintf(&b, `<circle class="av-fur" cx="%.1f" cy="%.1f" r="%.1f"/>`, cx, earY, earR)
104 fmt.Fprintf(&b, `<circle class="av-mark" cx="%.1f" cy="%.1f" r="%.1f"/>`, cx, earY, earR*0.34)
105 }
106 b.WriteString(`<ellipse class="av-head" cx="20" cy="23.6" rx="12.9" ry="11.5"/>`)
107
108 if t.Blaze {
109 // A blaze down the forehead.
110 b.WriteString(`<path class="av-mark" d="M20 12.8c1.2 2.1 1.2 4.2 0 6.4-1.2-2.2-1.2-4.3 0-6.4z"/>`)
111 }
112
113 switch t.Eyes {
114 case 0:
115 b.WriteString(`<circle class="av-ink" cx="15.4" cy="21.6" r="2"/>`)
116 b.WriteString(`<circle class="av-ink" cx="24.6" cy="21.6" r="2"/>`)
117 case 1:
118 b.WriteString(`<circle class="av-ring" cx="15.4" cy="21.6" r="2.2"/>`)
119 b.WriteString(`<circle class="av-ring" cx="24.6" cy="21.6" r="2.2"/>`)
120 default:
121 // Closed, content eyes.
122 b.WriteString(`<path class="av-line" d="M13.4 21.9c1.2-1.4 2.8-1.4 4 0M22.6 21.9c1.2-1.4 2.8-1.4 4 0"/>`)
123 }
124
125 b.WriteString(`<path class="av-nose" d="M18.5 26.6h3l-1.5 1.8z"/>`)
126 b.WriteString(`<path class="av-line" d="M20 28.4v1M20 29.4c-.6.8-1.7 1-2.5.4M20 29.4c.6.8 1.7 1 2.5.4"/>`)
127
128 if t.Whiskers > 0 {
129 // Two whiskers take the outer pair; three take all of them.
130 rows := []int{0, 2}
131 if t.Whiskers == 2 {
132 rows = []int{0, 1, 2}
133 }
134 left := [][2]float64{{10.9, 26.4}, {10.5, 27.8}, {10.9, 29.1}}
135 for _, i := range rows {
136 fmt.Fprintf(&b, `<path class="av-whisker" d="M17.6 %.1fL%.1f %.1f"/>`,
137 26.9+float64(i), left[i][0], left[i][1])
138 fmt.Fprintf(&b, `<path class="av-whisker" d="M22.4 %.1fL%.1f %.1f"/>`,
139 26.9+float64(i), 40-left[i][0], left[i][1])
140 }
141 }
142
143 b.WriteString(`</svg>`)
144 return template.HTML(b.String())
145 }