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_test.go +32−13

@@ -1,9 +1,9 @@
1 1 package web
2 2
3 // The portrait is a deterministic function of the name, and the markup is built
4 // from geometry rather than from the name. These tests hold both: the same name
5 // draws the same face, different names draw different faces, and a name cannot
6 // put anything into the SVG.
3 // The mark is a deterministic function of the name, and the markup is built from
4 // geometry rather than from the name. These tests hold both: the same name draws
5 // the same mark, different names draw different marks, and a name cannot put
6 // anything into the SVG.
7 7
8 8 import (
9 9 "encoding/xml"
@@ -32,7 +32,7 @@ func TestAvatarIsStableForAName(t *testing.T) {
32 32 for i := 0; i < 5; i++ {
33 33 again := portrait("alice")
34 34 if again.Tone != first.Tone || again.SVG != first.SVG {
35 t.Fatalf("portrait of alice changed between calls:\n%s\n%s", first.SVG, again.SVG)
35 t.Fatalf("mark for alice changed between calls:\n%s\n%s", first.SVG, again.SVG)
36 36 }
37 37 }
38 38 if portrait("alice").Tone != portrait("alice").Tone {
@@ -41,7 +41,7 @@ func TestAvatarIsStableForAName(t *testing.T) {
41 41 // A name is not case-folded into another account's face by accident: the
42 42 // canonical account name is what the caller passes, and that is what varies.
43 43 if portrait("alice").SVG == portrait("bob").SVG && portrait("alice").Tone == portrait("bob").Tone {
44 t.Fatal("two names drew the same portrait")
44 t.Fatal("two names drew the same mark")
45 45 }
46 46 }
47 47
@@ -72,7 +72,7 @@ func TestAvatarToneIsInRange(t *testing.T) {
72 72 for i := 0; i < 200; i++ {
73 73 name := "account-" + string(rune('a'+i%26)) + string(rune('a'+i/26))
74 74 if tone := portrait(name).Tone; tone < 1 || tone > 7 {
75 t.Fatalf("portrait(%q).Tone = %d, want 1..7", name, tone)
75 t.Fatalf("mark for %q has tone %d, want 1..7", name, tone)
76 76 }
77 77 }
78 78 }
@@ -101,16 +101,16 @@ func TestAvatarLinkedOnlyWhenTheNameIsAnAccount(t *testing.T) {
101 101
102 102 plain := s.web.avatar("alice")
103 103 if plain.Href != "" {
104 t.Fatalf("a bare portrait has an href: %+v", plain)
104 t.Fatalf("a bare mark has an href: %+v", plain)
105 105 }
106 106 linked := s.web.avatarUser(s.web.userRef("alice"))
107 107 if linked.Href != "/u/alice" {
108 t.Fatalf("linked portrait href = %q", linked.Href)
108 t.Fatalf("linked mark href = %q", linked.Href)
109 109 }
110 110 // A name that is not an account still gets a face, but no link to a 404.
111 111 stranger := s.web.avatarUser(s.web.userRef("nobody"))
112 112 if stranger.Href != "" || stranger.SVG == "" {
113 t.Fatalf("unknown account portrait = %+v", stranger)
113 t.Fatalf("unknown account mark = %+v", stranger)
114 114 }
115 115 }
116 116
@@ -119,14 +119,33 @@ func TestAvatarRendersInTheChrome(t *testing.T) {
119 119 s := newSite(t)
120 120 body := s.serve(t, nil, "/u/alice").Body.String()
121 121 if !strings.Contains(body, `<svg class="vole-avatar`) {
122 t.Fatalf("the profile does not render a portrait:\n%s", body)
122 t.Fatalf("the profile does not render a mark:\n%s", body)
123 123 }
124 124 // The portrait is decoration: the account name is beside it, so it is hidden
125 125 // from assistive technology and skipped by Tab.
126 126 if !strings.Contains(body, `aria-hidden="true"`) {
127 t.Fatalf("the profile portrait is not hidden from assistive technology:\n%s", body)
127 t.Fatalf("the profile mark is not hidden from assistive technology:\n%s", body)
128 128 }
129 129 if strings.Contains(body, `class="avatar c7"`) && !strings.Contains(body, "<svg") {
130 t.Fatalf("an avatar rendered without its portrait:\n%s", body)
130 t.Fatalf("an avatar rendered without its mark:\n%s", body)
131 }
132 }
133
134 // The mark is one stroke: no fills, no second shape. That is what keeps it quiet
135 // beside a name and legible at 17px, so a later change that adds a second element
136 // has to say so here.
137 func TestAvatarIsOneStroke(t *testing.T) {
138 t.Parallel()
139 markup := string(portrait("alice").SVG)
140 if got := strings.Count(markup, "<path"); got != 1 {
141 t.Fatalf("the mark has %d paths, want 1:\n%s", got, markup)
142 }
143 for _, unwanted := range []string{"<circle", "<ellipse", "<rect", "<polygon"} {
144 if strings.Contains(markup, unwanted) {
145 t.Fatalf("the mark carries %s:\n%s", unwanted, markup)
146 }
147 }
148 if !strings.Contains(markup, `fill="none"`) {
149 t.Fatalf("the mark does not say it is a stroke, so it would be filled without the sheet:\n%s", markup)
131 150 }
132 151 }