Skip to content

Commit 24ed69e

Browse files
sahil-noonsahil87
andauthored
feat: Per-Instance Accent Color (Host Color) (#435)
Co-authored-by: Sahil Ahuja <sahilahuja@gmail.com>
1 parent 2283643 commit 24ed69e

28 files changed

Lines changed: 1560 additions & 99 deletions

app/backend/api/router.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,8 @@ func (s *Server) buildRouter() chi.Router {
565565
r.Post("/api/settings/theme", s.handleSetTheme)
566566
r.Get("/api/settings/server-color", s.handleGetServerColor)
567567
r.Post("/api/settings/server-color", s.handleSetServerColor)
568+
r.Get("/api/settings/instance-color", s.handleGetInstanceColor)
569+
r.Post("/api/settings/instance-color", s.handleSetInstanceColor)
568570

569571
// Web Push: VAPID key (read), subscribe + notify (mutations, POST per §IX)
570572
r.Get("/api/push/vapid-public-key", s.handlePushVAPIDPublicKey)

app/backend/api/settings.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,39 @@ func (s *Server) handleGetServerColor(w http.ResponseWriter, r *http.Request) {
100100
writeJSON(w, http.StatusOK, map[string]any{"color": color})
101101
}
102102

103+
// handleGetInstanceColor returns the instance accent color.
104+
// GET /api/settings/instance-color → {"color": "4"} or {"color": null}
105+
// Returns the explicit setting only — the hostname-hash fallback is client-side.
106+
func (s *Server) handleGetInstanceColor(w http.ResponseWriter, r *http.Request) {
107+
color := settings.GetInstanceColor()
108+
writeJSON(w, http.StatusOK, map[string]any{"color": color})
109+
}
110+
111+
// handleSetInstanceColor sets or clears the instance accent color.
112+
// POST /api/settings/instance-color ← {"color": "4"} or {"color": "1+3"} or {"color": null}
113+
func (s *Server) handleSetInstanceColor(w http.ResponseWriter, r *http.Request) {
114+
var body struct {
115+
Color *string `json:"color"`
116+
}
117+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
118+
writeError(w, http.StatusBadRequest, "Invalid JSON body")
119+
return
120+
}
121+
if body.Color != nil {
122+
if errMsg := validate.ValidateColorValue(*body.Color); errMsg != "" {
123+
writeError(w, http.StatusBadRequest, errMsg)
124+
return
125+
}
126+
}
127+
128+
if err := settings.SetInstanceColor(body.Color); err != nil {
129+
s.logger.Error("failed to save instance color", "error", err)
130+
writeError(w, http.StatusInternalServerError, "failed to save setting")
131+
return
132+
}
133+
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
134+
}
135+
103136
// handleSetServerColor sets or clears the color for a server.
104137
// POST /api/settings/server-color ← {"server": "...", "color": 4} or {"server": "...", "color": null}
105138
func (s *Server) handleSetServerColor(w http.ResponseWriter, r *http.Request) {

app/backend/api/settings_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,100 @@ func TestSetServerColor_rejectsMalformed(t *testing.T) {
114114
}
115115
}
116116

117+
// --- GET/POST /api/settings/instance-color ---
118+
119+
func getInstanceColorViaAPI(t *testing.T, router http.Handler) *string {
120+
t.Helper()
121+
req := httptest.NewRequest(http.MethodGet, "/api/settings/instance-color", nil)
122+
rec := httptest.NewRecorder()
123+
router.ServeHTTP(rec, req)
124+
if rec.Code != http.StatusOK {
125+
t.Fatalf("GET status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
126+
}
127+
var result struct {
128+
Color *string `json:"color"`
129+
}
130+
if err := json.NewDecoder(rec.Body).Decode(&result); err != nil {
131+
t.Fatalf("decode: %v", err)
132+
}
133+
return result.Color
134+
}
135+
136+
func TestInstanceColor_getUnsetReturnsNull(t *testing.T) {
137+
isolateSettings(t)
138+
router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{})
139+
140+
if got := getInstanceColorViaAPI(t, router); got != nil {
141+
t.Errorf("color = %q, want null", *got)
142+
}
143+
}
144+
145+
func TestSetInstanceColor_persistsAndRoundTrips(t *testing.T) {
146+
isolateSettings(t)
147+
router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{})
148+
149+
for _, color := range []string{"5", "1+3"} {
150+
body := `{"color":"` + color + `"}`
151+
req := httptest.NewRequest(http.MethodPost, "/api/settings/instance-color", strings.NewReader(body))
152+
req.Header.Set("Content-Type", "application/json")
153+
rec := httptest.NewRecorder()
154+
router.ServeHTTP(rec, req)
155+
156+
if rec.Code != http.StatusOK {
157+
t.Fatalf("color %s: status = %d, want %d; body=%s", color, rec.Code, http.StatusOK, rec.Body.String())
158+
}
159+
if got := settings.GetInstanceColor(); got == nil || *got != color {
160+
t.Errorf("persisted color = %v, want %q", got, color)
161+
}
162+
if got := getInstanceColorViaAPI(t, router); got == nil || *got != color {
163+
t.Errorf("GET round-trip = %v, want %q", got, color)
164+
}
165+
}
166+
}
167+
168+
func TestSetInstanceColor_nullClears(t *testing.T) {
169+
isolateSettings(t)
170+
router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{})
171+
172+
color := "4"
173+
if err := settings.SetInstanceColor(&color); err != nil {
174+
t.Fatalf("seed: %v", err)
175+
}
176+
177+
body := `{"color":null}`
178+
req := httptest.NewRequest(http.MethodPost, "/api/settings/instance-color", strings.NewReader(body))
179+
req.Header.Set("Content-Type", "application/json")
180+
rec := httptest.NewRecorder()
181+
router.ServeHTTP(rec, req)
182+
183+
if rec.Code != http.StatusOK {
184+
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
185+
}
186+
if got := settings.GetInstanceColor(); got != nil {
187+
t.Errorf("color after clear = %q, want nil", *got)
188+
}
189+
if got := getInstanceColorViaAPI(t, router); got != nil {
190+
t.Errorf("GET after clear = %q, want null", *got)
191+
}
192+
}
193+
194+
func TestSetInstanceColor_rejectsMalformed(t *testing.T) {
195+
for _, bad := range []string{`{"color":"99"}`, `{"color":"1+"}`, `{"color":"x"}`, `{"color":"1+2+3"}`} {
196+
isolateSettings(t)
197+
router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{})
198+
req := httptest.NewRequest(http.MethodPost, "/api/settings/instance-color", strings.NewReader(bad))
199+
req.Header.Set("Content-Type", "application/json")
200+
rec := httptest.NewRecorder()
201+
router.ServeHTTP(rec, req)
202+
if rec.Code != http.StatusBadRequest {
203+
t.Errorf("body %s: status = %d, want %d", bad, rec.Code, http.StatusBadRequest)
204+
}
205+
if got := settings.GetInstanceColor(); got != nil {
206+
t.Errorf("body %s: malformed value persisted as %q, want nil", bad, *got)
207+
}
208+
}
209+
}
210+
117211
func TestSetServerColor_missingServer(t *testing.T) {
118212
isolateSettings(t)
119213
router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{})

app/backend/internal/settings/settings.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ type Settings struct {
1414
Theme string
1515
ThemeDark string
1616
ThemeLight string
17+
// InstanceColor is the per-instance accent color ("host color") — a color
18+
// value descriptor ("4" for a single ANSI index, "1+3" for a two-hue
19+
// blend). Scalar (one color per instance), unlike the ServerColors map.
20+
// Empty means "no explicit color set" — the frontend falls back to a
21+
// hostname-hash default. Stored as a string so a blend can round-trip;
22+
// reads tolerate a legacy bare integer (normalized on load).
23+
InstanceColor string
1724
// server name → color value descriptor ("4" for a single ANSI index,
1825
// "1+3" for a two-hue blend). Stored as a string so a blend can round-trip;
1926
// reads tolerate a legacy bare integer (normalized on load).
@@ -149,6 +156,13 @@ func parse(data string) Settings {
149156
if value != "" {
150157
s.ThemeLight = value
151158
}
159+
case "instance_color":
160+
// Tolerant read: accept a legacy bare integer OR the quoted string
161+
// descriptor ("1+3"); normalize and drop anything malformed.
162+
colorStr := strings.Trim(value, "\"")
163+
if normalized, ok := validate.NormalizeColorValue(colorStr); ok {
164+
s.InstanceColor = normalized
165+
}
152166
case "server_colors":
153167
inServerColors = true
154168
case "board_order":
@@ -164,6 +178,13 @@ func serialize(s Settings) string {
164178
"theme_dark: " + s.ThemeDark + "\n" +
165179
"theme_light: " + s.ThemeLight + "\n"
166180

181+
// Instance color — emitted only when non-empty so a settings file without
182+
// an instance color serializes byte-identically to the pre-change output.
183+
// Always quoted so a blend ("1+3") round-trips unambiguously.
184+
if s.InstanceColor != "" {
185+
out += "instance_color: \"" + s.InstanceColor + "\"\n"
186+
}
187+
167188
if len(s.ServerColors) > 0 {
168189
out += "server_colors:\n"
169190
// Sort keys for deterministic output.
@@ -214,6 +235,28 @@ func SetServerColor(server string, color *string) error {
214235
return Save(s)
215236
}
216237

238+
// GetInstanceColor returns the instance accent color-value descriptor, or nil
239+
// when no explicit color is set. Mirrors GetServerColor.
240+
func GetInstanceColor() *string {
241+
s := Load()
242+
if s.InstanceColor == "" {
243+
return nil
244+
}
245+
return &s.InstanceColor
246+
}
247+
248+
// SetInstanceColor sets or clears the instance accent color-value descriptor
249+
// (nil clears). Mirrors SetServerColor (load-then-save).
250+
func SetInstanceColor(color *string) error {
251+
s := Load()
252+
if color == nil {
253+
s.InstanceColor = ""
254+
} else {
255+
s.InstanceColor = *color
256+
}
257+
return Save(s)
258+
}
259+
217260
// GetBoardOrder returns the user-defined board display order (rank = index), or
218261
// nil when no order has been set. Mirrors GetServerColor.
219262
func GetBoardOrder() []string {

app/backend/internal/settings/settings_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,117 @@ func TestBoardOrderCoexistsWithServerColors(t *testing.T) {
323323
}
324324
}
325325

326+
func TestParseInstanceColor(t *testing.T) {
327+
// Tolerant read: quoted string descriptors, blends, and a legacy bare
328+
// integer all parse; malformed values are dropped (empty field).
329+
cases := []struct {
330+
in string
331+
want string
332+
}{
333+
{"instance_color: \"4\"\n", "4"},
334+
{"instance_color: \"1+3\"\n", "1+3"},
335+
{"instance_color: 4\n", "4"}, // legacy bare int
336+
{"instance_color: \"01\"\n", "1"}, // normalized
337+
{"instance_color: \"99\"\n", ""}, // out of range → dropped
338+
{"instance_color: \"1+2+3\"\n", ""}, // malformed → dropped
339+
{"theme: system\n", ""}, // absent → empty
340+
}
341+
for _, c := range cases {
342+
s := parse(c.in)
343+
if s.InstanceColor != c.want {
344+
t.Errorf("parse(%q).InstanceColor = %q, want %q", c.in, s.InstanceColor, c.want)
345+
}
346+
}
347+
}
348+
349+
func TestSerializeInstanceColor(t *testing.T) {
350+
s := Settings{
351+
Theme: "system", ThemeDark: "default-dark", ThemeLight: "default-light",
352+
InstanceColor: "1+3",
353+
}
354+
got := serialize(s)
355+
want := "theme: system\ntheme_dark: default-dark\ntheme_light: default-light\ninstance_color: \"1+3\"\n"
356+
if got != want {
357+
t.Errorf("serialize = %q, want %q", got, want)
358+
}
359+
}
360+
361+
func TestSerializeEmptyInstanceColorIsByteIdentical(t *testing.T) {
362+
// A Settings with no instance color must serialize exactly as before (no
363+
// instance_color: line), guarding the existing exact-string assertions.
364+
got := serialize(Settings{Theme: "system", ThemeDark: "default-dark", ThemeLight: "default-light"})
365+
want := "theme: system\ntheme_dark: default-dark\ntheme_light: default-light\n"
366+
if got != want {
367+
t.Errorf("serialize (empty InstanceColor) = %q, want %q", got, want)
368+
}
369+
}
370+
371+
func TestInstanceColorRoundTrip(t *testing.T) {
372+
tmp := t.TempDir()
373+
t.Setenv("HOME", tmp)
374+
375+
// Unset → nil.
376+
if got := GetInstanceColor(); got != nil {
377+
t.Errorf("GetInstanceColor (unset) = %v, want nil", got)
378+
}
379+
380+
color := "5"
381+
if err := SetInstanceColor(&color); err != nil {
382+
t.Fatalf("SetInstanceColor: %v", err)
383+
}
384+
got := GetInstanceColor()
385+
if got == nil || *got != "5" {
386+
t.Errorf("GetInstanceColor = %v, want \"5\"", got)
387+
}
388+
389+
// Blend round-trips through write→read as a string.
390+
blend := "1+3"
391+
if err := SetInstanceColor(&blend); err != nil {
392+
t.Fatalf("SetInstanceColor blend: %v", err)
393+
}
394+
got = GetInstanceColor()
395+
if got == nil || *got != "1+3" {
396+
t.Errorf("GetInstanceColor = %v, want \"1+3\"", got)
397+
}
398+
399+
// Clear.
400+
if err := SetInstanceColor(nil); err != nil {
401+
t.Fatalf("SetInstanceColor nil: %v", err)
402+
}
403+
if got := GetInstanceColor(); got != nil {
404+
t.Errorf("GetInstanceColor after clear = %v, want nil", got)
405+
}
406+
}
407+
408+
// TestInstanceColorCoexists verifies the scalar instance color persists and
409+
// loads alongside the nested server_colors map and board_order sequence.
410+
func TestInstanceColorCoexists(t *testing.T) {
411+
tmp := t.TempDir()
412+
t.Setenv("HOME", tmp)
413+
414+
serverColor := "4"
415+
if err := SetServerColor("default", &serverColor); err != nil {
416+
t.Fatalf("SetServerColor: %v", err)
417+
}
418+
instColor := "2"
419+
if err := SetInstanceColor(&instColor); err != nil {
420+
t.Fatalf("SetInstanceColor: %v", err)
421+
}
422+
if err := SetBoardOrder([]string{"x"}); err != nil {
423+
t.Fatalf("SetBoardOrder: %v", err)
424+
}
425+
loaded := Load()
426+
if loaded.InstanceColor != "2" {
427+
t.Errorf("InstanceColor = %q, want \"2\"", loaded.InstanceColor)
428+
}
429+
if loaded.ServerColors["default"] != "4" {
430+
t.Errorf("ServerColors[default] = %q, want \"4\"", loaded.ServerColors["default"])
431+
}
432+
if len(loaded.BoardOrder) != 1 || loaded.BoardOrder[0] != "x" {
433+
t.Errorf("BoardOrder = %v, want [x]", loaded.BoardOrder)
434+
}
435+
}
436+
326437
func TestServerColorRoundTrip(t *testing.T) {
327438
tmp := t.TempDir()
328439
t.Setenv("HOME", tmp)

app/frontend/index.html

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,18 @@
2020
resolved = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
2121
}
2222
document.documentElement.dataset.theme = resolved;
23+
// Instance-accent tint (paint cache): the runtime echoes the resolved
24+
// accent's theme-color hex to localStorage on every load, so an
25+
// installed PWA window opens already tinted (no flash). Malformed or
26+
// absent echo falls back to the per-mode defaults; the runtime
27+
// resolution corrects/rewrites both the meta tag and the echo.
28+
var tint = null;
29+
try {
30+
var echo = JSON.parse(localStorage.getItem("runkit-instance-color"));
31+
if (echo && typeof echo.hex === "string" && /^#[0-9a-fA-F]{6}$/.test(echo.hex)) tint = echo.hex;
32+
} catch(e) {}
2333
var tc = document.querySelector('meta[name="theme-color"]');
24-
if (tc) tc.setAttribute('content', resolved === 'dark' ? '#0f1117' : '#f8f9fb');
34+
if (tc) tc.setAttribute('content', tint || (resolved === 'dark' ? '#0f1117' : '#f8f9fb'));
2535
})();
2636
</script>
2737
<link rel="icon" type="image/svg+xml" href="/generated-icons/favicon.svg" />

app/frontend/src/api/client.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,26 @@ export async function setServerColor(server: string, color: string | null): Prom
765765
if (!res.ok) await throwOnError(res);
766766
}
767767

768+
// --- Instance accent color (per-instance "host color", scalar) ---
769+
770+
/** The explicit instance accent color descriptor ("4" / "1+3"), or null when
771+
* unset (the frontend then falls back to the hostname-hash default). */
772+
export async function getInstanceColor(): Promise<string | null> {
773+
const res = await deduplicatedFetch("/api/settings/instance-color");
774+
if (!res.ok) await throwOnError(res);
775+
const data: { color: string | null } = await res.json();
776+
return data.color;
777+
}
778+
779+
export async function setInstanceColor(color: string | null): Promise<void> {
780+
const res = await fetch("/api/settings/instance-color", {
781+
method: "POST",
782+
headers: { "Content-Type": "application/json" },
783+
body: JSON.stringify({ color }),
784+
});
785+
if (!res.ok) await throwOnError(res);
786+
}
787+
768788
// --- Web Push ---
769789

770790
/** Fetch the server's VAPID public key (base64url) for pushManager.subscribe. */

0 commit comments

Comments
 (0)