Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/backend/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,8 @@ func (s *Server) buildRouter() chi.Router {
r.Post("/api/settings/theme", s.handleSetTheme)
r.Get("/api/settings/server-color", s.handleGetServerColor)
r.Post("/api/settings/server-color", s.handleSetServerColor)
r.Get("/api/settings/instance-color", s.handleGetInstanceColor)
r.Post("/api/settings/instance-color", s.handleSetInstanceColor)

// Web Push: VAPID key (read), subscribe + notify (mutations, POST per §IX)
r.Get("/api/push/vapid-public-key", s.handlePushVAPIDPublicKey)
Expand Down
33 changes: 33 additions & 0 deletions app/backend/api/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,39 @@ func (s *Server) handleGetServerColor(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"color": color})
}

// handleGetInstanceColor returns the instance accent color.
// GET /api/settings/instance-color → {"color": "4"} or {"color": null}
// Returns the explicit setting only — the hostname-hash fallback is client-side.
func (s *Server) handleGetInstanceColor(w http.ResponseWriter, r *http.Request) {
color := settings.GetInstanceColor()
writeJSON(w, http.StatusOK, map[string]any{"color": color})
}

// handleSetInstanceColor sets or clears the instance accent color.
// POST /api/settings/instance-color ← {"color": "4"} or {"color": "1+3"} or {"color": null}
func (s *Server) handleSetInstanceColor(w http.ResponseWriter, r *http.Request) {
var body struct {
Color *string `json:"color"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON body")
return
}
if body.Color != nil {
if errMsg := validate.ValidateColorValue(*body.Color); errMsg != "" {
writeError(w, http.StatusBadRequest, errMsg)
return
}
}

if err := settings.SetInstanceColor(body.Color); err != nil {
s.logger.Error("failed to save instance color", "error", err)
writeError(w, http.StatusInternalServerError, "failed to save setting")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

// handleSetServerColor sets or clears the color for a server.
// POST /api/settings/server-color ← {"server": "...", "color": 4} or {"server": "...", "color": null}
func (s *Server) handleSetServerColor(w http.ResponseWriter, r *http.Request) {
Expand Down
94 changes: 94 additions & 0 deletions app/backend/api/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,100 @@ func TestSetServerColor_rejectsMalformed(t *testing.T) {
}
}

// --- GET/POST /api/settings/instance-color ---

func getInstanceColorViaAPI(t *testing.T, router http.Handler) *string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/settings/instance-color", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var result struct {
Color *string `json:"color"`
}
if err := json.NewDecoder(rec.Body).Decode(&result); err != nil {
t.Fatalf("decode: %v", err)
}
return result.Color
}

func TestInstanceColor_getUnsetReturnsNull(t *testing.T) {
isolateSettings(t)
router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{})

if got := getInstanceColorViaAPI(t, router); got != nil {
t.Errorf("color = %q, want null", *got)
}
}

func TestSetInstanceColor_persistsAndRoundTrips(t *testing.T) {
isolateSettings(t)
router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{})

for _, color := range []string{"5", "1+3"} {
body := `{"color":"` + color + `"}`
req := httptest.NewRequest(http.MethodPost, "/api/settings/instance-color", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("color %s: status = %d, want %d; body=%s", color, rec.Code, http.StatusOK, rec.Body.String())
}
if got := settings.GetInstanceColor(); got == nil || *got != color {
t.Errorf("persisted color = %v, want %q", got, color)
}
if got := getInstanceColorViaAPI(t, router); got == nil || *got != color {
t.Errorf("GET round-trip = %v, want %q", got, color)
}
}
}

func TestSetInstanceColor_nullClears(t *testing.T) {
isolateSettings(t)
router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{})

color := "4"
if err := settings.SetInstanceColor(&color); err != nil {
t.Fatalf("seed: %v", err)
}

body := `{"color":null}`
req := httptest.NewRequest(http.MethodPost, "/api/settings/instance-color", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if got := settings.GetInstanceColor(); got != nil {
t.Errorf("color after clear = %q, want nil", *got)
}
if got := getInstanceColorViaAPI(t, router); got != nil {
t.Errorf("GET after clear = %q, want null", *got)
}
}

func TestSetInstanceColor_rejectsMalformed(t *testing.T) {
for _, bad := range []string{`{"color":"99"}`, `{"color":"1+"}`, `{"color":"x"}`, `{"color":"1+2+3"}`} {
isolateSettings(t)
router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{})
req := httptest.NewRequest(http.MethodPost, "/api/settings/instance-color", strings.NewReader(bad))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("body %s: status = %d, want %d", bad, rec.Code, http.StatusBadRequest)
}
if got := settings.GetInstanceColor(); got != nil {
t.Errorf("body %s: malformed value persisted as %q, want nil", bad, *got)
}
}
}

func TestSetServerColor_missingServer(t *testing.T) {
isolateSettings(t)
router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{})
Expand Down
43 changes: 43 additions & 0 deletions app/backend/internal/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ type Settings struct {
Theme string
ThemeDark string
ThemeLight string
// InstanceColor is the per-instance accent color ("host color") — a color
// value descriptor ("4" for a single ANSI index, "1+3" for a two-hue
// blend). Scalar (one color per instance), unlike the ServerColors map.
// Empty means "no explicit color set" — the frontend falls back to a
// hostname-hash default. Stored as a string so a blend can round-trip;
// reads tolerate a legacy bare integer (normalized on load).
InstanceColor string
// server name → color value descriptor ("4" for a single ANSI index,
// "1+3" for a two-hue blend). Stored as a string so a blend can round-trip;
// reads tolerate a legacy bare integer (normalized on load).
Expand Down Expand Up @@ -149,6 +156,13 @@ func parse(data string) Settings {
if value != "" {
s.ThemeLight = value
}
case "instance_color":
// Tolerant read: accept a legacy bare integer OR the quoted string
// descriptor ("1+3"); normalize and drop anything malformed.
colorStr := strings.Trim(value, "\"")
if normalized, ok := validate.NormalizeColorValue(colorStr); ok {
s.InstanceColor = normalized
}
case "server_colors":
inServerColors = true
case "board_order":
Expand All @@ -164,6 +178,13 @@ func serialize(s Settings) string {
"theme_dark: " + s.ThemeDark + "\n" +
"theme_light: " + s.ThemeLight + "\n"

// Instance color — emitted only when non-empty so a settings file without
// an instance color serializes byte-identically to the pre-change output.
// Always quoted so a blend ("1+3") round-trips unambiguously.
if s.InstanceColor != "" {
out += "instance_color: \"" + s.InstanceColor + "\"\n"
}

if len(s.ServerColors) > 0 {
out += "server_colors:\n"
// Sort keys for deterministic output.
Expand Down Expand Up @@ -214,6 +235,28 @@ func SetServerColor(server string, color *string) error {
return Save(s)
}

// GetInstanceColor returns the instance accent color-value descriptor, or nil
// when no explicit color is set. Mirrors GetServerColor.
func GetInstanceColor() *string {
s := Load()
if s.InstanceColor == "" {
return nil
}
return &s.InstanceColor
}

// SetInstanceColor sets or clears the instance accent color-value descriptor
// (nil clears). Mirrors SetServerColor (load-then-save).
func SetInstanceColor(color *string) error {
s := Load()
if color == nil {
s.InstanceColor = ""
} else {
s.InstanceColor = *color
}
return Save(s)
}

// GetBoardOrder returns the user-defined board display order (rank = index), or
// nil when no order has been set. Mirrors GetServerColor.
func GetBoardOrder() []string {
Expand Down
111 changes: 111 additions & 0 deletions app/backend/internal/settings/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,117 @@ func TestBoardOrderCoexistsWithServerColors(t *testing.T) {
}
}

func TestParseInstanceColor(t *testing.T) {
// Tolerant read: quoted string descriptors, blends, and a legacy bare
// integer all parse; malformed values are dropped (empty field).
cases := []struct {
in string
want string
}{
{"instance_color: \"4\"\n", "4"},
{"instance_color: \"1+3\"\n", "1+3"},
{"instance_color: 4\n", "4"}, // legacy bare int
{"instance_color: \"01\"\n", "1"}, // normalized
{"instance_color: \"99\"\n", ""}, // out of range → dropped
{"instance_color: \"1+2+3\"\n", ""}, // malformed → dropped
{"theme: system\n", ""}, // absent → empty
}
for _, c := range cases {
s := parse(c.in)
if s.InstanceColor != c.want {
t.Errorf("parse(%q).InstanceColor = %q, want %q", c.in, s.InstanceColor, c.want)
}
}
}

func TestSerializeInstanceColor(t *testing.T) {
s := Settings{
Theme: "system", ThemeDark: "default-dark", ThemeLight: "default-light",
InstanceColor: "1+3",
}
got := serialize(s)
want := "theme: system\ntheme_dark: default-dark\ntheme_light: default-light\ninstance_color: \"1+3\"\n"
if got != want {
t.Errorf("serialize = %q, want %q", got, want)
}
}

func TestSerializeEmptyInstanceColorIsByteIdentical(t *testing.T) {
// A Settings with no instance color must serialize exactly as before (no
// instance_color: line), guarding the existing exact-string assertions.
got := serialize(Settings{Theme: "system", ThemeDark: "default-dark", ThemeLight: "default-light"})
want := "theme: system\ntheme_dark: default-dark\ntheme_light: default-light\n"
if got != want {
t.Errorf("serialize (empty InstanceColor) = %q, want %q", got, want)
}
}

func TestInstanceColorRoundTrip(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)

// Unset → nil.
if got := GetInstanceColor(); got != nil {
t.Errorf("GetInstanceColor (unset) = %v, want nil", got)
}

color := "5"
if err := SetInstanceColor(&color); err != nil {
t.Fatalf("SetInstanceColor: %v", err)
}
got := GetInstanceColor()
if got == nil || *got != "5" {
t.Errorf("GetInstanceColor = %v, want \"5\"", got)
}

// Blend round-trips through write→read as a string.
blend := "1+3"
if err := SetInstanceColor(&blend); err != nil {
t.Fatalf("SetInstanceColor blend: %v", err)
}
got = GetInstanceColor()
if got == nil || *got != "1+3" {
t.Errorf("GetInstanceColor = %v, want \"1+3\"", got)
}

// Clear.
if err := SetInstanceColor(nil); err != nil {
t.Fatalf("SetInstanceColor nil: %v", err)
}
if got := GetInstanceColor(); got != nil {
t.Errorf("GetInstanceColor after clear = %v, want nil", got)
}
}

// TestInstanceColorCoexists verifies the scalar instance color persists and
// loads alongside the nested server_colors map and board_order sequence.
func TestInstanceColorCoexists(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)

serverColor := "4"
if err := SetServerColor("default", &serverColor); err != nil {
t.Fatalf("SetServerColor: %v", err)
}
instColor := "2"
if err := SetInstanceColor(&instColor); err != nil {
t.Fatalf("SetInstanceColor: %v", err)
}
if err := SetBoardOrder([]string{"x"}); err != nil {
t.Fatalf("SetBoardOrder: %v", err)
}
loaded := Load()
if loaded.InstanceColor != "2" {
t.Errorf("InstanceColor = %q, want \"2\"", loaded.InstanceColor)
}
if loaded.ServerColors["default"] != "4" {
t.Errorf("ServerColors[default] = %q, want \"4\"", loaded.ServerColors["default"])
}
if len(loaded.BoardOrder) != 1 || loaded.BoardOrder[0] != "x" {
t.Errorf("BoardOrder = %v, want [x]", loaded.BoardOrder)
}
}

func TestServerColorRoundTrip(t *testing.T) {
tmp := t.TempDir()
t.Setenv("HOME", tmp)
Expand Down
12 changes: 11 additions & 1 deletion app/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,18 @@
resolved = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
document.documentElement.dataset.theme = resolved;
// Instance-accent tint (paint cache): the runtime echoes the resolved
// accent's theme-color hex to localStorage on every load, so an
// installed PWA window opens already tinted (no flash). Malformed or
// absent echo falls back to the per-mode defaults; the runtime
// resolution corrects/rewrites both the meta tag and the echo.
var tint = null;
try {
var echo = JSON.parse(localStorage.getItem("runkit-instance-color"));
if (echo && typeof echo.hex === "string" && /^#[0-9a-fA-F]{6}$/.test(echo.hex)) tint = echo.hex;
} catch(e) {}
var tc = document.querySelector('meta[name="theme-color"]');
if (tc) tc.setAttribute('content', resolved === 'dark' ? '#0f1117' : '#f8f9fb');
if (tc) tc.setAttribute('content', tint || (resolved === 'dark' ? '#0f1117' : '#f8f9fb'));
})();
</script>
<link rel="icon" type="image/svg+xml" href="/generated-icons/favicon.svg" />
Expand Down
20 changes: 20 additions & 0 deletions app/frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,26 @@ export async function setServerColor(server: string, color: string | null): Prom
if (!res.ok) await throwOnError(res);
}

// --- Instance accent color (per-instance "host color", scalar) ---

/** The explicit instance accent color descriptor ("4" / "1+3"), or null when
* unset (the frontend then falls back to the hostname-hash default). */
export async function getInstanceColor(): Promise<string | null> {
const res = await deduplicatedFetch("/api/settings/instance-color");
if (!res.ok) await throwOnError(res);
const data: { color: string | null } = await res.json();
return data.color;
}

export async function setInstanceColor(color: string | null): Promise<void> {
const res = await fetch("/api/settings/instance-color", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ color }),
});
if (!res.ok) await throwOnError(res);
}

// --- Web Push ---

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