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