tenseleyflow/shithub / 016e8fe

Browse files

Add deterministic SVG identicon (5x5 mirrored, Primer accent palette, sha256-seeded)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
016e8feb4de1bce3f80dbb67963b40f8edaf154f
Parents
a088685
Tree
2544929

2 changed files

StatusFile+-
A internal/avatars/identicon.go 78 0
A internal/avatars/identicon_test.go 56 0
internal/avatars/identicon.goadded
@@ -0,0 +1,78 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package avatars owns avatar resolution for shithub user profiles. S09
4
+// ships the deterministic SVG identicon (rendered server-side, no upload
5
+// needed) plus the routing skeleton for serving uploaded avatars from
6
+// object storage. Real upload (resize / EXIF-strip) lands in S10.
7
+package avatars
8
+
9
+import (
10
+	"crypto/sha256"
11
+	"fmt"
12
+	"strings"
13
+)
14
+
15
+// IdenticonSize is the SVG viewBox side length. The image is square and
16
+// renders at any CSS px size via width/height attributes.
17
+const IdenticonSize = 5
18
+
19
+// palette is the fixed accent set used to color identicons. Sourced from
20
+// Primer's accent ramp so the result feels native to the rest of the
21
+// shithub chrome. 12 entries gives reasonable variety without
22
+// overwhelming the page.
23
+var palette = []string{
24
+	"#e85aad", // pink
25
+	"#bf3989", // pink-deep
26
+	"#a371f7", // purple
27
+	"#8957e5", // purple-deep
28
+	"#388bfd", // blue
29
+	"#1f6feb", // blue-deep
30
+	"#3fb950", // green
31
+	"#238636", // green-deep
32
+	"#d29922", // yellow-deep
33
+	"#db6d28", // orange
34
+	"#bd561d", // orange-deep
35
+	"#f85149", // red
36
+}
37
+
38
+// Identicon returns an inline SVG identicon for username. Same input →
39
+// same output (deterministic). The SVG carries width/height attributes
40
+// so it can be embedded as inline markup OR served as a standalone file.
41
+//
42
+// The pattern is a 5×5 grid mirrored horizontally — left two columns
43
+// are mirrored to the right, the middle column is independent. Each
44
+// cell is colored if the corresponding bit in the digest is 1.
45
+func Identicon(username string, pixelSize int) string {
46
+	if pixelSize <= 0 {
47
+		pixelSize = 80
48
+	}
49
+	digest := sha256.Sum256([]byte(strings.ToLower(username)))
50
+	color := palette[int(digest[0])%len(palette)]
51
+
52
+	var b strings.Builder
53
+	fmt.Fprintf(&b,
54
+		`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" shape-rendering="crispEdges" role="img" aria-label="identicon">`,
55
+		pixelSize, pixelSize, IdenticonSize, IdenticonSize)
56
+	fmt.Fprintf(&b, `<rect width="%d" height="%d" fill="#21262d"/>`, IdenticonSize, IdenticonSize)
57
+
58
+	// Walk 15 cells (5 rows × 3 unique columns). Mirror the first two
59
+	// columns onto the last two so the identicon is symmetric.
60
+	bit := 0
61
+	for row := 0; row < IdenticonSize; row++ {
62
+		for col := 0; col < 3; col++ {
63
+			b8 := digest[1+(bit/8)] // bytes 1..15 cover the 25 bits we sample
64
+			on := (b8 >> (uint(bit) % 8)) & 1
65
+			bit++
66
+			if on != 1 {
67
+				continue
68
+			}
69
+			fmt.Fprintf(&b, `<rect x="%d" y="%d" width="1" height="1" fill="%s"/>`, col, row, color)
70
+			if col < 2 {
71
+				mirror := IdenticonSize - 1 - col
72
+				fmt.Fprintf(&b, `<rect x="%d" y="%d" width="1" height="1" fill="%s"/>`, mirror, row, color)
73
+			}
74
+		}
75
+	}
76
+	b.WriteString(`</svg>`)
77
+	return b.String()
78
+}
internal/avatars/identicon_test.goadded
@@ -0,0 +1,56 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package avatars
4
+
5
+import (
6
+	"strings"
7
+	"testing"
8
+)
9
+
10
+func TestIdenticon_Deterministic(t *testing.T) {
11
+	t.Parallel()
12
+	a := Identicon("alice", 80)
13
+	b := Identicon("alice", 80)
14
+	if a != b {
15
+		t.Fatal("Identicon must be deterministic for the same username")
16
+	}
17
+}
18
+
19
+func TestIdenticon_DifferentUsers(t *testing.T) {
20
+	t.Parallel()
21
+	a := Identicon("alice", 80)
22
+	b := Identicon("bob", 80)
23
+	if a == b {
24
+		t.Fatal("different usernames should produce different SVGs")
25
+	}
26
+}
27
+
28
+func TestIdenticon_CaseInsensitive(t *testing.T) {
29
+	t.Parallel()
30
+	if Identicon("Alice", 80) != Identicon("alice", 80) {
31
+		t.Fatal("identicon should not change with username case")
32
+	}
33
+}
34
+
35
+func TestIdenticon_ContainsSVGSkeleton(t *testing.T) {
36
+	t.Parallel()
37
+	out := Identicon("alice", 64)
38
+	for _, want := range []string{
39
+		`<svg `, `</svg>`, `viewBox="0 0 5 5"`, `<rect`,
40
+	} {
41
+		if !strings.Contains(out, want) {
42
+			t.Errorf("missing %q in svg", want)
43
+		}
44
+	}
45
+}
46
+
47
+func TestIdenticon_Symmetry(t *testing.T) {
48
+	t.Parallel()
49
+	out := Identicon("symmetry", 80)
50
+	// For each <rect x="0" ..> we expect a corresponding <rect x="4" ..>.
51
+	leftCount := strings.Count(out, `x="0"`)
52
+	rightCount := strings.Count(out, `x="4"`)
53
+	if leftCount != rightCount {
54
+		t.Fatalf("asymmetric: left col rects=%d, right col rects=%d", leftCount, rightCount)
55
+	}
56
+}