Commit 60019a10fc

60019a10fcb05a6afe4552e7190c2df6562a2234

parent: 82af2bbc90

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 17:54 UTC

Label model and the web's chip colours

Layout: unified · split

gitbay/Issues/IssueLabel.swift added +114
@@ -0,0 +1,114 @@
1import SwiftUI
2
3/// One row of `label list`: the label, the colour it was set to, and how
4/// many issues carry it.
5///
6/// Named `IssueLabel` rather than `Label` because SwiftUI's `Label` is
7/// used throughout this module and a bare `Label` here would shadow it.
8nonisolated struct IssueLabel: Decodable, Sendable, Hashable, Identifiable {
9 let name: String
10 /// `#rrggbb` when set, absent otherwise — `label set` normalises to
11 /// lowercase with the hash, so any other shape never reached the store.
12 let color: String?
13 let issues: Int
14
15 var id: String { name }
16}
17
18/// Chip colours for labels, matching what the web paints
19/// (`internal/httpd/web.go`, `labelColors` and `clampChip`) so the same
20/// label reads the same on both surfaces.
21nonisolated enum LabelPalette {
22
23 /// Mid-tone hues that stay legible as text on both grounds. Every
24 /// entry already sits inside the window `clamped` holds, so a derived
25 /// colour is a plain lookup and only a stored one is ever adjusted.
26 static let palette = [
27 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
28 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
29 ]
30
31 /// The colour the web would paint for this label: the stored one when
32 /// it is a hex colour, otherwise one picked from the palette by name.
33 static func hex(name: String, stored: String?) -> String {
34 guard let stored, let parsed = parse(stored) else {
35 return palette[Int(fnv1a(name) % UInt32(palette.count))]
36 }
37 return clamped(parsed)
38 }
39
40 /// `#rrggbb` to its three channels, or nil for anything else.
41 private static func parse(_ hex: String) -> (Double, Double, Double)? {
42 guard hex.count == 7, hex.hasPrefix("#") else { return nil }
43 let digits = hex.dropFirst()
44 guard digits.allSatisfy(\.isHexDigit),
45 let value = UInt32(digits, radix: 16) else { return nil }
46 return (
47 Double((value >> 16) & 0xFF) / 255,
48 Double((value >> 8) & 0xFF) / 255,
49 Double(value & 0xFF) / 255
50 )
51 }
52
53 /// Hold relative luminance between 0.12 and 0.28, where a chip clears
54 /// 3:1 against white and against the dark ground alike. The hue is
55 /// kept; the channels are scaled in linear light.
56 private static func clamped(_ rgb: (Double, Double, Double)) -> String {
57 let (r, g, b) = (linear(rgb.0), linear(rgb.1), linear(rgb.2))
58 let y = 0.2126 * r + 0.7152 * g + 0.0722 * b
59 let lo = 0.12, hi = 0.28
60 if y >= lo, y <= hi { return format(r, g, b) }
61 let target = y < lo ? lo : hi
62 guard y != 0 else { return format(target, target, target) }
63 let k = target / y
64 return format(min(1, r * k), min(1, g * k), min(1, b * k))
65 }
66
67 private static func linear(_ c: Double) -> Double {
68 c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
69 }
70
71 private static func format(_ r: Double, _ g: Double, _ b: Double) -> String {
72 func encode(_ v: Double) -> Int {
73 let s = v <= 0.0031308 ? v * 12.92 : 1.055 * pow(v, 1 / 2.4) - 0.055
74 return Int((s * 255).rounded())
75 }
76 return String(format: "#%02x%02x%02x", encode(r), encode(g), encode(b))
77 }
78
79 /// FNV-1a, 32-bit. The web hashes the name with it to index the
80 /// palette, so this has to agree bit for bit.
81 private static func fnv1a(_ text: String) -> UInt32 {
82 var hash: UInt32 = 2_166_136_261
83 for byte in text.utf8 {
84 hash ^= UInt32(byte)
85 hash &*= 16_777_619
86 }
87 return hash
88 }
89}
90
91/// The chip colour for every label in one repository. Built from one
92/// `label list` read; a label it has never heard of still derives a
93/// colour from its name rather than falling back to grey.
94nonisolated struct LabelColors: Sendable, Hashable {
95
96 private let stored: [String: String]
97
98 init(_ labels: [IssueLabel] = []) {
99 stored = labels.reduce(into: [:]) { map, label in
100 map[label.name] = label.color ?? ""
101 }
102 }
103
104 func hex(_ name: String) -> String {
105 LabelPalette.hex(name: name, stored: stored[name])
106 }
107
108 func color(_ name: String) -> Color {
109 guard let value = UInt32(hex(name).dropFirst(), radix: 16) else {
110 return .secondary
111 }
112 return Color(UIColor(hex: value))
113 }
114}
gitbayTests/LabelTests.swift added +100
@@ -0,0 +1,100 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5struct LabelPaletteTests {
6
7 // Golden values computed from the web's own algorithm: FNV-1a 32 of
8 // the name, modulo the eight-colour palette. If these drift, the two
9 // surfaces paint the same label differently.
10 @Test func derivesAStableColourFromTheName() {
11 #expect(LabelPalette.hex(name: "bug", stored: nil) == "#b93a86")
12 #expect(LabelPalette.hex(name: "ci", stored: nil) == "#1a7f37")
13 #expect(LabelPalette.hex(name: "docs", stored: nil) == "#9a6700")
14 #expect(LabelPalette.hex(name: "ops", stored: nil) == "#cf222e")
15 #expect(LabelPalette.hex(name: "mobile", stored: nil) == "#bf5b16")
16 }
17
18 @Test func theSameNameAlwaysDerivesTheSameColour() {
19 #expect(LabelPalette.hex(name: "bug", stored: nil)
20 == LabelPalette.hex(name: "bug", stored: nil))
21 }
22
23 /// A stored colour already inside the luminance window is used as-is.
24 @Test func keepsAStoredColourThatIsAlreadyLegible() {
25 #expect(LabelPalette.hex(name: "bug", stored: "#ff0000") == "#ff0000")
26 #expect(LabelPalette.hex(name: "bug", stored: "#0969da") == "#0969da")
27 }
28
29 /// Too dark or too bright is scaled back into the window, so a chip
30 /// stays readable as text on both grounds.
31 @Test func clampsAStoredColourOutsideTheWindow() {
32 #expect(LabelPalette.hex(name: "bug", stored: "#000000") == "#616161")
33 #expect(LabelPalette.hex(name: "bug", stored: "#ffffff") == "#909090")
34 }
35
36 @Test func upperCaseStoredColoursNormaliseDown() {
37 #expect(LabelPalette.hex(name: "bug", stored: "#FF0000") == "#ff0000")
38 }
39
40 /// `label set` stores `#rrggbb`. Anything else — an empty string, a
41 /// bare six digits, a name — never reached the store as a colour, so
42 /// it is treated as unset and the name derives one.
43 @Test func anythingThatIsNotAHexColourFallsBackToTheName() {
44 let derived = LabelPalette.hex(name: "bug", stored: nil)
45 #expect(LabelPalette.hex(name: "bug", stored: "") == derived)
46 #expect(LabelPalette.hex(name: "bug", stored: "ff0000") == derived)
47 #expect(LabelPalette.hex(name: "bug", stored: "red") == derived)
48 #expect(LabelPalette.hex(name: "bug", stored: "#fff") == derived)
49 }
50
51 @Test func everyPaletteEntryIsAlreadyLegible() {
52 // No palette colour is ever clamped, so a derived colour is a
53 // plain lookup. If this fails the palette drifted from the web's.
54 for hex in LabelPalette.palette {
55 #expect(LabelPalette.hex(name: "x", stored: hex) == hex)
56 }
57 }
58}
59
60struct IssueLabelDecodingTests {
61
62 @Test func decodesAListRow() throws {
63 let json = Data("""
64 [{"name":"bug","color":"#cf222e","issues":18},{"name":"ci","issues":11}]
65 """.utf8)
66 let labels = try JSONDecoder().decode([IssueLabel].self, from: json)
67
68 #expect(labels.count == 2)
69 #expect(labels[0].name == "bug")
70 #expect(labels[0].color == "#cf222e")
71 #expect(labels[0].issues == 18)
72 // `color` is omitempty on the wire — absent, not null, when unset.
73 #expect(labels[1].color == nil)
74 #expect(labels[1].issues == 11)
75 }
76
77 @Test func theIdIsTheName() throws {
78 let json = Data(#"[{"name":"bug","issues":0}]"#.utf8)
79 let labels = try JSONDecoder().decode([IssueLabel].self, from: json)
80 #expect(labels[0].id == "bug")
81 }
82}
83
84struct LabelColorsTests {
85
86 @Test func looksUpAStoredColour() {
87 let colors = LabelColors([
88 IssueLabel(name: "bug", color: "#cf222e", issues: 1),
89 IssueLabel(name: "ci", color: nil, issues: 2),
90 ])
91 #expect(colors.hex("bug") == "#cf222e")
92 #expect(colors.hex("ci") == "#1a7f37")
93 }
94
95 /// An issue can carry a label the map has not loaded — a stale list, or
96 /// a label added since. It still derives a colour rather than going grey.
97 @Test func anUnknownLabelStillDerivesItsColour() {
98 #expect(LabelColors().hex("docs") == "#9a6700")
99 }
100}