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 | } | |