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