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.

Replace the vole faces with an abstract mark

ca098f628ce251501444a286932ff388d52a543c · Keenan Nemetz · 8d ago · parent 753cd703

Replace the vole faces with an abstract mark

The faces were too literal for the rest of the interface. A name now
gets one curled stroke instead: a burrow turned in on itself, a fern
frond, a shell, whichever the reader sees. It never says which.

The point is what it does not do. It is one cream line on one of seven
earthy grounds, with the same round caps and stroke language for
everyone, so it is quiet next to a name and still legible at 17px. A few
shapes were drawn and dropped on the way: a crescent is elegant but
barely changes between names, arches read as rainbows or smiles, and
rings, mosaics and tally marks read as icons. A curl carries enough
variation (how far it winds, tightness, direction, rotation, thickness,
position) to tell people apart without looking like a diagram.

The derivation is unchanged: SHA-256 over the name, nothing stored, no
upload, no avatar service. The path is marked `fill="none"` in the
markup; the sheet owns the stroke colour, so the palette stays a theme
decision.

server/web/avatar.go +78−92

@@ -1,47 +1,57 @@
1 1 package web
2 2
3 // A name's avatar: a small vole, drawn from the name.
3 // A name's avatar: an abstract mark derived from the name.
4 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.
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.
11 11 //
12 // How it is drawn is the whole of the design:
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.
17 //
18 // How it is drawn:
13 19 //
14 20 // - 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 // 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.
21 26 //
22 27 // Colours live in the stylesheet rather than in the markup, so the palette stays a
23 // theme decision and the SVG carries only geometry.
28 // theme decision and the SVG carries only geometry. The name is never
29 // interpolated into the markup; a test holds that.
24 30
25 31 import (
26 32 "crypto/sha256"
27 33 "fmt"
28 34 "html/template"
35 "math"
29 36 "strings"
30 37 )
31 38
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 // 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.
39 46 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
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
45 55 }
46 56
47 57 // avatarView is one portrait ready to render. Href is empty for a name that is
@@ -52,94 +62,70 @@ type avatarView struct {
52 62 SVG template.HTML
53 63 }
54 64
55 // avatar is the portrait for a name, on its own.
65 // avatar is the mark for a name, on its own.
56 66 func (w *Web) avatar(name string) avatarView { return portrait(name) }
57 67
58 // avatarUser is the portrait for an account, linked to the profile it belongs to
59 // when the name resolves to one.
68 // avatarUser is the mark for an account, linked to the profile it belongs to when
69 // the name resolves to one.
60 70 func (w *Web) avatarUser(ref userRef) avatarView {
61 71 v := portrait(ref.Name)
62 72 v.Href = ref.Href
63 73 return v
64 74 }
65 75
66 // portrait derives the face from the name. SHA-256 rather than a cheap hash
76 // portrait derives the mark from the name. SHA-256 rather than a cheap hash
67 77 // 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,
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,
70 80 // never because a store or a rebuild moved.
71 81 func portrait(name string) avatarView {
72 82 sum := sha256.Sum256([]byte(name))
73 83 return avatarView{
74 84 Tone: int(sum[8]%7) + 1,
75 85 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,
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,
81 94 }),
82 95 }
83 96 }
84 97
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.
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.
88 101 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"/>`)
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
123 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
124 113
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])
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
140 126 }
127 fmt.Fprintf(&b, "L%.1f %.1f", x, y)
141 128 }
142
143 b.WriteString(`</svg>`)
129 b.WriteString(`"/></svg>`)
144 130 return template.HTML(b.String())
145 131 }