From f8e369cbd4075c03562b31aec0525aa6f13eb803 Mon Sep 17 00:00:00 2001 From: Sahil Ahuja Date: Wed, 22 Jul 2026 02:15:33 +0530 Subject: [PATCH 1/6] feat: Per-Instance Accent Color (Host Color) --- app/backend/api/router.go | 2 + app/backend/api/settings.go | 33 +++ app/backend/api/settings_test.go | 94 ++++++++ app/backend/internal/settings/settings.go | 43 ++++ .../internal/settings/settings_test.go | 111 +++++++++ app/frontend/index.html | 12 +- app/frontend/src/api/client.ts | 20 ++ app/frontend/src/app.tsx | 44 ++-- app/frontend/src/components/sidebar.test.tsx | 16 ++ .../components/sidebar/collapsible-panel.tsx | 2 +- .../components/sidebar/host-panel.test.tsx | 123 +++++++++- .../src/components/sidebar/host-panel.tsx | 85 ++++++- .../src/components/sidebar/index.test.tsx | 16 ++ .../contexts/instance-accent-context.test.tsx | 158 +++++++++++++ .../src/contexts/instance-accent-context.tsx | 138 ++++++++++++ app/frontend/src/contexts/theme-context.tsx | 9 +- app/frontend/src/instance-accent.test.ts | 120 ++++++++++ app/frontend/src/instance-accent.ts | 127 +++++++++++ docs/memory/run-kit/architecture.md | 9 +- docs/memory/run-kit/index.md | 2 +- docs/memory/run-kit/ui-patterns.md | 44 +++- .../.history.jsonl | 13 ++ .../.status.yaml | 55 +++++ .../intake.md | 112 ++++++++++ .../plan.md | 211 ++++++++++++++++++ 25 files changed, 1558 insertions(+), 41 deletions(-) create mode 100644 app/frontend/src/contexts/instance-accent-context.test.tsx create mode 100644 app/frontend/src/contexts/instance-accent-context.tsx create mode 100644 app/frontend/src/instance-accent.test.ts create mode 100644 app/frontend/src/instance-accent.ts create mode 100644 fab/changes/260721-1etw-instance-accent-host-color/.history.jsonl create mode 100644 fab/changes/260721-1etw-instance-accent-host-color/.status.yaml create mode 100644 fab/changes/260721-1etw-instance-accent-host-color/intake.md create mode 100644 fab/changes/260721-1etw-instance-accent-host-color/plan.md diff --git a/app/backend/api/router.go b/app/backend/api/router.go index 287dc60d..e6e3168e 100644 --- a/app/backend/api/router.go +++ b/app/backend/api/router.go @@ -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) diff --git a/app/backend/api/settings.go b/app/backend/api/settings.go index e0d7f592..2f988f2e 100644 --- a/app/backend/api/settings.go +++ b/app/backend/api/settings.go @@ -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) { diff --git a/app/backend/api/settings_test.go b/app/backend/api/settings_test.go index 304eca20..b364ea3d 100644 --- a/app/backend/api/settings_test.go +++ b/app/backend/api/settings_test.go @@ -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{}) diff --git a/app/backend/internal/settings/settings.go b/app/backend/internal/settings/settings.go index c85fb292..b52673a9 100644 --- a/app/backend/internal/settings/settings.go +++ b/app/backend/internal/settings/settings.go @@ -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). @@ -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": @@ -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. @@ -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 { diff --git a/app/backend/internal/settings/settings_test.go b/app/backend/internal/settings/settings_test.go index 6232570a..671f29b1 100644 --- a/app/backend/internal/settings/settings_test.go +++ b/app/backend/internal/settings/settings_test.go @@ -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) diff --git a/app/frontend/index.html b/app/frontend/index.html index 167e6e11..22c4bcab 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -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')); })(); diff --git a/app/frontend/src/api/client.ts b/app/frontend/src/api/client.ts index 517f9937..0a74d67d 100644 --- a/app/frontend/src/api/client.ts +++ b/app/frontend/src/api/client.ts @@ -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 { + 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 { + 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. */ diff --git a/app/frontend/src/app.tsx b/app/frontend/src/app.tsx index da850ff3..4cd73e54 100644 --- a/app/frontend/src/app.tsx +++ b/app/frontend/src/app.tsx @@ -47,6 +47,7 @@ import { type PendingSwitchTarget, } from "@/lib/window-transition"; import { ThemeProvider, useTheme, useThemeActions } from "@/contexts/theme-context"; +import { InstanceAccentProvider, useInstanceAccent } from "@/contexts/instance-accent-context"; import { SessionProvider } from "@/contexts/session-context"; import { ToastProvider } from "@/components/toast"; import { OptimisticProvider } from "@/contexts/optimistic-context"; @@ -145,19 +146,21 @@ export function RootWrapper() { return ( - - - - - - - - - - - - - + + + + + + + + + + + + + + + ); @@ -184,6 +187,12 @@ export function RootWrapper() { * lazy-chunk load (e.g. the board) blanks the body while the bar stays painted. */ export function AppLayout() { + // Instance accent (1etw): a 2px stripe across the top of the persistent top + // bar plus a subtle wash behind it — the "which run-kit instance is this" + // color channel (server colors own the sidebar). Both hexes are theme-derived + // (contrast-guarded stripe, ~6.5% background blend wash); nothing renders + // until an accent is resolved. + const { stripeHex, washHex } = useInstanceAccent(); return (
` would nest two `role="banner"` landmarks. This wrapper only owns the `shrink-0` sizing that keeps the bar at its natural height above the - `flex-1` content region. */} -
+ `flex-1` content region (plus the instance-accent stripe/wash — the + TopBar header has no background of its own, so the wash on this + wrapper shows through). */} +
+ {stripeHex && ( +
diff --git a/app/frontend/src/components/sidebar.test.tsx b/app/frontend/src/components/sidebar.test.tsx index c621d8e0..bd444af3 100644 --- a/app/frontend/src/components/sidebar.test.tsx +++ b/app/frontend/src/components/sidebar.test.tsx @@ -5,11 +5,23 @@ import { OptimisticProvider, useOptimisticContext } from "@/contexts/optimistic- import { HostMetricsProvider, MetricsProvider, StandaloneSessionContextProvider } from "@/contexts/session-context"; import { FocusedPaneProvider } from "@/contexts/focused-pane-context"; import { ThemeProvider } from "@/contexts/theme-context"; +import { InstanceAccentValueProvider, type InstanceAccent } from "@/contexts/instance-accent-context"; import { ChromeProvider } from "@/contexts/chrome-context"; import { ToastProvider } from "@/components/toast"; import { useWindowStore, entryKey } from "@/store/window-store"; import type { ProjectSession } from "@/types"; +// HostPanel (inside Sidebar) consumes the instance-accent context; inject a +// static null accent so sidebar tests need no fetching provider (1etw). +const NULL_ACCENT: InstanceAccent = { + color: null, + isExplicit: false, + stripeHex: null, + washHex: null, + setColor: () => {}, +}; + + const mockNavigate = vi.fn(); vi.mock("@tanstack/react-router", () => ({ useNavigate: () => mockNavigate, @@ -130,6 +142,7 @@ function buildTree(overrides: SidebarTestOverrides) { } = overrides; return ( + + ); } @@ -963,6 +977,7 @@ describe("Sidebar", () => { render( + { + , ); diff --git a/app/frontend/src/components/sidebar/collapsible-panel.tsx b/app/frontend/src/components/sidebar/collapsible-panel.tsx index bd43d4ea..09d95b6e 100644 --- a/app/frontend/src/components/sidebar/collapsible-panel.tsx +++ b/app/frontend/src/components/sidebar/collapsible-panel.tsx @@ -266,7 +266,7 @@ export function CollapsiblePanel({
{/* Header — always visible */}
{ (e.currentTarget as HTMLElement).style.backgroundColor = headerHoverBg; } : undefined} onMouseLeave={headerHoverBg && headerBg ? (e) => { (e.currentTarget as HTMLElement).style.backgroundColor = headerBg; } : undefined} diff --git a/app/frontend/src/components/sidebar/host-panel.test.tsx b/app/frontend/src/components/sidebar/host-panel.test.tsx index 6e0d9201..c8e6a2b8 100644 --- a/app/frontend/src/components/sidebar/host-panel.test.tsx +++ b/app/frontend/src/components/sidebar/host-panel.test.tsx @@ -1,9 +1,37 @@ -import { describe, it, expect, afterEach, beforeEach } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; import { HostPanel } from "./host-panel"; import { HostMetricsProvider, MetricsProvider } from "@/contexts/session-context"; +import { + InstanceAccentValueProvider, + type InstanceAccent, +} from "@/contexts/instance-accent-context"; +import { ThemeProvider } from "@/contexts/theme-context"; import type { MetricsSnapshot } from "@/types"; +// SwatchPopover (opened by the accent picker) reads useTheme; mock the API +// client so ThemeProvider makes no real HTTP calls. +vi.mock("@/api/client", () => ({ + getThemePreference: vi.fn().mockRejectedValue(new Error("no API in test")), + setThemePreference: vi.fn().mockResolvedValue(undefined), +})); + +function mockMatchMedia() { + vi.stubGlobal( + "matchMedia", + vi.fn().mockReturnValue({ + matches: true, + media: "(prefers-color-scheme: dark)", + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + onchange: null, + dispatchEvent: vi.fn(), + }), + ); +} + function snapshot(hostname: string): MetricsSnapshot { return { hostname, @@ -15,30 +43,49 @@ function snapshot(hostname: string): MetricsSnapshot { }; } +function accentValue(overrides: Partial = {}): InstanceAccent { + return { + color: null, + isExplicit: false, + stripeHex: null, + washHex: null, + setColor: vi.fn(), + ...overrides, + }; +} + function renderPanel({ server, host, isConnected = true, + accent = accentValue(), }: { server: MetricsSnapshot | null; host: MetricsSnapshot | null; isConnected?: boolean; + accent?: InstanceAccent; }) { return render( - - - - - , + + + + + + + + + , ); } beforeEach(() => { localStorage.clear(); + mockMatchMedia(); }); afterEach(() => { cleanup(); + vi.unstubAllGlobals(); localStorage.clear(); }); @@ -71,3 +118,65 @@ describe("HostPanel metrics fallback (260720-zx4i)", () => { expect(screen.getByTitle("SSE disconnected")).toBeInTheDocument(); }); }); + +describe("HostPanel instance accent (1etw)", () => { + it("tints the hostname with the accent stripe hex", () => { + renderPanel({ + server: snapshot("tinted-host"), + host: null, + accent: accentValue({ color: "4", stripeHex: "#3355aa", washHex: "#111318" }), + }); + const name = screen.getByText("tinted-host"); + expect(name).toHaveStyle({ color: "#3355aa" }); + }); + + it("renders the hostname untinted when no accent is resolved", () => { + renderPanel({ server: snapshot("plain-host"), host: null, accent: accentValue() }); + const name = screen.getByText("plain-host"); + expect(name.className).toContain("text-text-primary"); + expect(name.style.color).toBe(""); + }); + + it("opens the color-only SwatchPopover from the header palette button", async () => { + renderPanel({ + server: snapshot("h"), + host: null, + accent: accentValue({ color: "4", stripeHex: "#3355aa", washHex: "#111318" }), + }); + fireEvent.click(screen.getByLabelText("Set instance color")); + expect(screen.getByText("Clear color")).toBeInTheDocument(); + }); + + it("a swatch pick writes through setColor and closes the popover", async () => { + const setColor = vi.fn(); + renderPanel({ + server: snapshot("h"), + host: null, + accent: accentValue({ color: "4", isExplicit: true, stripeHex: "#3355aa", setColor }), + }); + fireEvent.click(screen.getByLabelText("Set instance color")); + // Pick the first color swatch (family names are the option labels). + fireEvent.click(screen.getByRole("option", { name: /red/i })); + expect(setColor).toHaveBeenCalledTimes(1); + // The popover maps family → legacy descriptor at its write seam ("red" → "1"). + expect(setColor).toHaveBeenCalledWith("1"); + expect(screen.queryByText("Clear color")).not.toBeInTheDocument(); + }); + + it("Clear color sends null (restores the hash default)", async () => { + const setColor = vi.fn(); + renderPanel({ + server: snapshot("h"), + host: null, + accent: accentValue({ color: "4", isExplicit: true, stripeHex: "#3355aa", setColor }), + }); + fireEvent.click(screen.getByLabelText("Set instance color")); + fireEvent.click(screen.getByText("Clear color")); + expect(setColor).toHaveBeenCalledWith(null); + }); + + it("the picker is available even when no metrics have arrived", async () => { + renderPanel({ server: null, host: null, accent: accentValue() }); + expect(screen.getByLabelText("Set instance color")).toBeInTheDocument(); + }); +}); diff --git a/app/frontend/src/components/sidebar/host-panel.tsx b/app/frontend/src/components/sidebar/host-panel.tsx index 9c19193c..de34d465 100644 --- a/app/frontend/src/components/sidebar/host-panel.tsx +++ b/app/frontend/src/components/sidebar/host-panel.tsx @@ -1,6 +1,11 @@ +import { useState, useRef, useLayoutEffect } from "react"; +import { createPortal } from "react-dom"; import { CollapsiblePanel } from "./collapsible-panel"; +import { PaletteIcon } from "./icons"; import { HostMetrics } from "../host-metrics"; +import { SwatchPopover } from "@/components/swatch-popover"; import { useHostMetrics, useMetrics } from "@/contexts/session-context"; +import { useInstanceAccent } from "@/contexts/instance-accent-context"; type HostPanelProps = { /** Health of whatever source feeds this panel: the current server's @@ -18,9 +23,42 @@ export function HostPanel({ isConnected }: HostPanelProps) { const serverMetrics = useMetrics(); const hostMetrics = useHostMetrics(); const metrics = serverMetrics ?? hostMetrics; + + // Instance accent (1etw): the hostname carries the instance's accent color + // and the header hosts the accent picker — the HOST panel is the "which + // run-kit instance is this" surface (server colors own the sidebar rows). + const { color, isExplicit, stripeHex, setColor } = useInstanceAccent(); + const [showColorPicker, setShowColorPicker] = useState(false); + const paletteBtnRef = useRef(null); + const [popoverPos, setPopoverPos] = useState<{ top: number; right: number } | null>(null); + // Portal the popover to document.body at fixed coordinates anchored at the + // palette button (right-aligned, flip-above heuristic) so it escapes the + // panel's overflow clip — the x4sf ServerGroup-header precedent. The HOST + // panel sits at the sidebar's bottom, so the flip-above branch is the norm. + useLayoutEffect(() => { + if (!showColorPicker || !paletteBtnRef.current) { + setPopoverPos(null); + return; + } + const rect = paletteBtnRef.current.getBoundingClientRect(); + const approxPopoverHeight = 190; // color-only grid: Clear row + 3 swatch rows + const below = rect.bottom + 4; + const fitsBelow = below + approxPopoverHeight <= window.innerHeight; + const top = fitsBelow ? below : Math.max(4, rect.top - approxPopoverHeight - 4); + setPopoverPos({ + top, + right: Math.max(4, window.innerWidth - rect.right), + }); + }, [showColorPicker]); + const hostnameHeader = metrics ? ( <> - {metrics.hostname} + + {metrics.hostname} + ) : null; + // Sibling of the header toggle button (the `headerAction` slot) — a button + // nested inside the toggle would be invalid markup. Hover-revealed with the + // touch/keyboard fallbacks (the session-row convention). + const paletteAction = ( + <> + + {showColorPicker && popoverPos && createPortal( +
+ { + setColor(c); + setShowColorPicker(false); + }} + onClose={() => setShowColorPicker(false)} + /> +
, + document.body, + )} + + ); + return ( - + {!metrics ? (
No metrics
) : ( diff --git a/app/frontend/src/components/sidebar/index.test.tsx b/app/frontend/src/components/sidebar/index.test.tsx index a1fefda0..e95c94f0 100644 --- a/app/frontend/src/components/sidebar/index.test.tsx +++ b/app/frontend/src/components/sidebar/index.test.tsx @@ -6,6 +6,7 @@ import { OptimisticProvider } from "@/contexts/optimistic-context"; import { HostMetricsProvider, MetricsProvider, StandaloneSessionContextProvider } from "@/contexts/session-context"; import { FocusedPaneProvider, useRegisterFocusedPane, type FocusedPane } from "@/contexts/focused-pane-context"; import { ThemeProvider } from "@/contexts/theme-context"; +import { InstanceAccentValueProvider, type InstanceAccent } from "@/contexts/instance-accent-context"; import { ChromeProvider } from "@/contexts/chrome-context"; import { ToastProvider } from "@/components/toast"; import { useWindowStore } from "@/store/window-store"; @@ -18,6 +19,17 @@ import { } from "@/themes"; import type { MetricsSnapshot, ProjectSession } from "@/types"; +// HostPanel (inside Sidebar) consumes the instance-accent context; inject a +// static null accent so sidebar tests need no fetching provider (1etw). +const NULL_ACCENT: InstanceAccent = { + color: null, + isExplicit: false, + stripeHex: null, + washHex: null, + setColor: () => {}, +}; + + const mockNavigate = vi.fn(); vi.mock("@tanstack/react-router", () => ({ useNavigate: () => mockNavigate, @@ -106,6 +118,7 @@ function renderSidebar(opts: RenderOpts = {}) { ); const tree = ( + + ); return render(opts.strict ? {tree} : tree); @@ -425,6 +439,7 @@ describe("Sidebar — tree ARIA + roving keyboard navigation (wt1v)", () => { const sessionsByServer = new Map([["primary", sessions]]); return ( + { + ); } diff --git a/app/frontend/src/contexts/instance-accent-context.test.tsx b/app/frontend/src/contexts/instance-accent-context.test.tsx new file mode 100644 index 00000000..7c030371 --- /dev/null +++ b/app/frontend/src/contexts/instance-accent-context.test.tsx @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react"; +import { ThemeProvider } from "@/contexts/theme-context"; +import { ToastProvider } from "@/components/toast"; +import { InstanceAccentProvider, useInstanceAccent } from "./instance-accent-context"; +import { + hashHostnameColor, + readInstanceColorEcho, + writeInstanceColorEcho, +} from "@/instance-accent"; + +// Mock the API client module so no real HTTP calls happen in tests. +vi.mock("@/api/client", () => ({ + getThemePreference: vi.fn().mockRejectedValue(new Error("no API in test")), + setThemePreference: vi.fn().mockResolvedValue(undefined), + getInstanceColor: vi.fn(), + setInstanceColor: vi.fn().mockResolvedValue(undefined), + getHealth: vi.fn(), +})); +import { getInstanceColor, setInstanceColor, getHealth } from "@/api/client"; + +function mockMatchMedia() { + vi.stubGlobal( + "matchMedia", + vi.fn().mockReturnValue({ + matches: true, + media: "(prefers-color-scheme: dark)", + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + onchange: null, + dispatchEvent: vi.fn(), + }), + ); +} + +function Probe() { + const a = useInstanceAccent(); + return ( +
+ {String(a.color)} + {String(a.isExplicit)} + {String(a.stripeHex)} + {String(a.washHex)} + + +
+ ); +} + +function renderProvider() { + return render( + + + + + + + , + ); +} + +beforeEach(() => { + localStorage.clear(); + mockMatchMedia(); + document.head.innerHTML = ''; + vi.mocked(getInstanceColor).mockReset(); + vi.mocked(getHealth).mockReset(); + vi.mocked(setInstanceColor).mockClear(); + vi.mocked(setInstanceColor).mockResolvedValue(undefined); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + localStorage.clear(); + document.head.innerHTML = ""; +}); + +describe("InstanceAccentProvider resolution chain", () => { + it("explicit setting wins over the hostname hash and localStorage", async () => { + writeInstanceColorEcho({ value: "2", hex: "#00ff00" }); + vi.mocked(getInstanceColor).mockResolvedValue("5"); + vi.mocked(getHealth).mockResolvedValue({ status: "ok", hostname: "gcp-box" }); + + renderProvider(); + await waitFor(() => expect(screen.getByTestId("color").textContent).toBe("5")); + expect(screen.getByTestId("explicit").textContent).toBe("true"); + expect(screen.getByTestId("stripe").textContent).toMatch(/^#[0-9a-f]{6}$/i); + // Echo rewritten with the authoritative value. + await waitFor(() => expect(readInstanceColorEcho()?.value).toBe("5")); + // Meta carries the accent hex, not the bare background. + const meta = document.querySelector('meta[name="theme-color"]'); + expect(meta?.getAttribute("content")).toBe(screen.getByTestId("stripe").textContent); + }); + + it("falls back to the deterministic hostname hash when no explicit color is set", async () => { + vi.mocked(getInstanceColor).mockResolvedValue(null); + vi.mocked(getHealth).mockResolvedValue({ status: "ok", hostname: "gcp-box" }); + + renderProvider(); + const expected = hashHostnameColor("gcp-box"); + await waitFor(() => expect(screen.getByTestId("color").textContent).toBe(expected)); + expect(screen.getByTestId("explicit").textContent).toBe("false"); + expect(screen.getByTestId("wash").textContent).toMatch(/^#[0-9a-f]{6}$/i); + }); + + it("seeds the first paint from the echo while fetches are pending", async () => { + writeInstanceColorEcho({ value: "3", hex: "#aabb00" }); + vi.mocked(getInstanceColor).mockReturnValue(new Promise(() => {})); + vi.mocked(getHealth).mockReturnValue(new Promise(() => {})); + + renderProvider(); + expect(screen.getByTestId("color").textContent).toBe("3"); + expect(screen.getByTestId("explicit").textContent).toBe("false"); + }); + + it("yields no accent (and clears the echo) when nothing is set and the hostname is unknown", async () => { + writeInstanceColorEcho({ value: "3", hex: "#aabb00" }); + vi.mocked(getInstanceColor).mockResolvedValue(null); + vi.mocked(getHealth).mockRejectedValue(new Error("health down")); + + renderProvider(); + await waitFor(() => expect(screen.getByTestId("color").textContent).toBe("null")); + expect(screen.getByTestId("stripe").textContent).toBe("null"); + await waitFor(() => expect(readInstanceColorEcho()).toBeNull()); + }); +}); + +describe("InstanceAccentProvider setColor", () => { + it("persists a pick through the API and flips to explicit", async () => { + vi.mocked(getInstanceColor).mockResolvedValue(null); + vi.mocked(getHealth).mockResolvedValue({ status: "ok", hostname: "gcp-box" }); + + renderProvider(); + await waitFor(() => expect(screen.getByTestId("color").textContent).not.toBe("null")); + + fireEvent.click(screen.getByText("pick5")); + expect(screen.getByTestId("color").textContent).toBe("5"); + expect(screen.getByTestId("explicit").textContent).toBe("true"); + expect(setInstanceColor).toHaveBeenCalledWith("5"); + }); + + it("clearing restores the hostname-hash default without reload", async () => { + vi.mocked(getInstanceColor).mockResolvedValue("5"); + vi.mocked(getHealth).mockResolvedValue({ status: "ok", hostname: "gcp-box" }); + + renderProvider(); + await waitFor(() => expect(screen.getByTestId("color").textContent).toBe("5")); + + fireEvent.click(screen.getByText("clear")); + const expected = hashHostnameColor("gcp-box"); + await waitFor(() => expect(screen.getByTestId("color").textContent).toBe(expected)); + expect(screen.getByTestId("explicit").textContent).toBe("false"); + expect(setInstanceColor).toHaveBeenCalledWith(null); + }); +}); diff --git a/app/frontend/src/contexts/instance-accent-context.tsx b/app/frontend/src/contexts/instance-accent-context.tsx new file mode 100644 index 00000000..84cebd01 --- /dev/null +++ b/app/frontend/src/contexts/instance-accent-context.tsx @@ -0,0 +1,138 @@ +import { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { useTheme } from "@/contexts/theme-context"; +import { useToast } from "@/components/toast"; +import { getInstanceColor, setInstanceColor as setInstanceColorApi, getHealth } from "@/api/client"; +import { + hashHostnameColor, + readInstanceColorEcho, + writeInstanceColorEcho, + deriveAccentHexes, + setAccentThemeColor, +} from "@/instance-accent"; + +/** + * Instance accent ("host color") — root-mounted provider owning the accent + * resolution chain (explicit `instance_color` setting → localStorage echo as a + * pre-fetch paint seed → hostname hash), the localStorage echo, and the PWA + * theme-color meta bridge. Both rendering surfaces (the top-bar stripe/wash in + * AppLayout and the HOST panel hostname/picker) consume `useInstanceAccent()` + * from this single instance — one fetch, one state, and a pick in the HOST + * panel repaints the top bar without a reload. + */ +export type InstanceAccent = { + /** Resolved accent descriptor ("4" / "1+3"), or null when no accent applies + * (hostname unknown and nothing explicit/echoed). */ + color: string | null; + /** True when an explicit instance color is set (vs the hash default). */ + isExplicit: boolean; + /** Contrast-guarded accent hex — top-bar stripe, HOST hostname tint, and + * the theme-color meta content. Null when no accent is resolved. */ + stripeHex: string | null; + /** Subtle accent-into-background blend for the top-bar wash. */ + washHex: string | null; + /** Set (descriptor) or clear (null → hash default) the instance color. + * Optimistic; POSTs to the instance host, toasting on failure. */ + setColor: (color: string | null) => void; +}; + +const InstanceAccentContext = createContext(null); + +export function InstanceAccentProvider({ children }: { children: React.ReactNode }) { + const { theme } = useTheme(); + const { addToast } = useToast(); + + // undefined = fetch pending; null = fetched, no explicit color set. + const [explicit, setExplicit] = useState(undefined); + // null = fetch pending; "" = fetched but unknown (health failed / empty). + const [hostname, setHostname] = useState(null); + // Pre-fetch paint seed from the last load's echo (paint cache only — the + // authoritative resolution below overwrites it as soon as fetches land). + const [echoSeed] = useState(() => readInstanceColorEcho()?.value ?? null); + + // Fetch the explicit setting + hostname once on mount (guarded for + // StrictMode double-invoke, matching the AppShell getHealth pattern). + const didFetchRef = useRef(false); + useEffect(() => { + if (didFetchRef.current) return; + didFetchRef.current = true; + getInstanceColor() + .then((color) => setExplicit(color)) + .catch(() => setExplicit(null)); + getHealth() + .then((data) => setHostname(data.hostname ?? "")) + .catch(() => setHostname("")); + }, []); + + // Resolution chain: explicit setting → (while pending) echo seed → hostname + // hash. `authoritative` marks the point where the chain no longer depends on + // the paint seed — only then may the echo/meta be cleared on a null accent. + const resolved = + typeof explicit === "string" + ? explicit + : explicit === null && hostname !== null + ? hashHostnameColor(hostname) + : echoSeed; + const authoritative = typeof explicit === "string" || (explicit === null && hostname !== null); + + const hexes = useMemo( + () => (resolved != null ? deriveAccentHexes(resolved, theme) : null), + [resolved, theme], + ); + + // Bridge: keep the theme-color meta and the localStorage echo in sync with + // the resolved accent under the active theme. + useEffect(() => { + if (resolved != null && hexes != null) { + setAccentThemeColor(hexes.stripeHex); + writeInstanceColorEcho({ value: resolved, hex: hexes.stripeHex }); + } else if (authoritative) { + setAccentThemeColor(null); + writeInstanceColorEcho(null); + } + }, [resolved, hexes, authoritative]); + + // Clear the accent meta on unmount so a torn-down provider (tests) doesn't + // leak module state into the next mount. + useEffect(() => () => setAccentThemeColor(null), []); + + const setColor = useCallback( + (color: string | null) => { + setExplicit(color); + setInstanceColorApi(color).catch((err: unknown) => { + addToast(err instanceof Error && err.message ? err.message : "Failed to save instance color"); + }); + }, + [addToast], + ); + + const value = useMemo( + () => ({ + color: resolved, + isExplicit: typeof explicit === "string", + stripeHex: hexes?.stripeHex ?? null, + washHex: hexes?.washHex ?? null, + setColor, + }), + [resolved, explicit, hexes, setColor], + ); + + return {children}; +} + +/** Test seam: inject a fixed accent value without the fetching provider. + * Mirrors the session-context `MetricsProvider` value-injection pattern. */ +export function InstanceAccentValueProvider({ + value, + children, +}: { + value: InstanceAccent; + children: React.ReactNode; +}) { + return {children}; +} + +export function useInstanceAccent(): InstanceAccent { + const ctx = useContext(InstanceAccentContext); + if (!ctx) throw new Error("useInstanceAccent must be used within InstanceAccentProvider"); + return ctx; +} diff --git a/app/frontend/src/contexts/theme-context.tsx b/app/frontend/src/contexts/theme-context.tsx index 63a1ca35..9ae30301 100644 --- a/app/frontend/src/contexts/theme-context.tsx +++ b/app/frontend/src/contexts/theme-context.tsx @@ -8,6 +8,7 @@ import { } from "@/themes"; import type { Theme, UIColors } from "@/themes"; import { getThemePreference, setThemePreference } from "@/api/client"; +import { applyThemeColorMeta } from "@/instance-accent"; export type ResolvedTheme = "light" | "dark"; @@ -86,9 +87,11 @@ function applyThemeToDOM(theme: Theme): void { // Set color-scheme root.style.setProperty("color-scheme", theme.category); - // Update meta theme-color - const tc = document.querySelector('meta[name="theme-color"]'); - if (tc) tc.setAttribute("content", theme.palette.background); + // Update meta theme-color through the shared single writer — the instance + // accent (when resolved) wins over the bare background, and funneling both + // writers through one module removes the child-vs-parent effect-ordering + // race that would otherwise let a theme switch clobber the accent tint. + applyThemeColorMeta(theme.palette.background); } export function ThemeProvider({ children }: { children: React.ReactNode }) { diff --git a/app/frontend/src/instance-accent.test.ts b/app/frontend/src/instance-accent.test.ts new file mode 100644 index 00000000..5538ccdb --- /dev/null +++ b/app/frontend/src/instance-accent.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + hashHostnameColor, + readInstanceColorEcho, + writeInstanceColorEcho, + deriveAccentHexes, + applyThemeColorMeta, + setAccentThemeColor, + INSTANCE_COLOR_STORAGE_KEY, +} from "./instance-accent"; +import { DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME } from "./themes"; + +beforeEach(() => { + localStorage.clear(); + document.head.innerHTML = ''; +}); + +afterEach(() => { + // Reset the module's writer state so tests don't leak accents into each other. + setAccentThemeColor(null); + localStorage.clear(); + document.head.innerHTML = ""; +}); + +function metaContent(): string | null { + return document.querySelector('meta[name="theme-color"]')?.getAttribute("content") ?? null; +} + +describe("hashHostnameColor", () => { + it("is deterministic and lands in descriptors 1-6", () => { + const hosts = ["laptop", "mac-mini.local", "gcp-box", "a", "very-long-hostname.example.com"]; + for (const h of hosts) { + const first = hashHostnameColor(h); + expect(first).toBe(hashHostnameColor(h)); + expect(first).toMatch(/^[1-6]$/); + } + }); + + it("returns null for an empty hostname", () => { + expect(hashHostnameColor("")).toBeNull(); + }); + + it("distinguishes at least some distinct hostnames", () => { + const colors = new Set( + ["laptop", "mac-mini", "gcp-box", "pi", "nas"].map((h) => hashHostnameColor(h)), + ); + expect(colors.size).toBeGreaterThan(1); + }); +}); + +describe("instance color echo", () => { + it("round-trips a {value, hex} payload", () => { + writeInstanceColorEcho({ value: "1+3", hex: "#aa5522" }); + expect(readInstanceColorEcho()).toEqual({ value: "1+3", hex: "#aa5522" }); + }); + + it("clears on null", () => { + writeInstanceColorEcho({ value: "4", hex: "#3355aa" }); + writeInstanceColorEcho(null); + expect(readInstanceColorEcho()).toBeNull(); + expect(localStorage.getItem(INSTANCE_COLOR_STORAGE_KEY)).toBeNull(); + }); + + it("ignores malformed or wrong-shape values silently", () => { + for (const bad of ["not json{", "42", '"just a string"', '{"value":7,"hex":true}', '{"value":"4"}']) { + localStorage.setItem(INSTANCE_COLOR_STORAGE_KEY, bad); + expect(readInstanceColorEcho()).toBeNull(); + } + }); + + it("returns null when nothing is stored", () => { + expect(readInstanceColorEcho()).toBeNull(); + }); +}); + +describe("deriveAccentHexes", () => { + it("derives stripe and wash hexes for single and blend descriptors, per theme", () => { + for (const value of ["4", "1+3"]) { + for (const theme of [DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME]) { + const hexes = deriveAccentHexes(value, theme); + expect(hexes).not.toBeNull(); + expect(hexes?.stripeHex).toMatch(/^#[0-9a-f]{6}$/i); + expect(hexes?.washHex).toMatch(/^#[0-9a-f]{6}$/i); + // The wash is a near-background blend, never the raw accent. + expect(hexes?.washHex).not.toBe(hexes?.stripeHex); + } + } + }); + + it("returns null for an unrecognized value", () => { + expect(deriveAccentHexes("99", DEFAULT_DARK_THEME)).toBeNull(); + }); + + it("recomputes per palette (dark vs light differ)", () => { + const dark = deriveAccentHexes("4", DEFAULT_DARK_THEME); + const light = deriveAccentHexes("4", DEFAULT_LIGHT_THEME); + expect(dark?.washHex).not.toBe(light?.washHex); + }); +}); + +describe("theme-color meta writer (single writer, last-wins content)", () => { + it("writes the background when no accent is set", () => { + applyThemeColorMeta("#101010"); + expect(metaContent()).toBe("#101010"); + }); + + it("accent wins over a later theme apply (the clobber fix)", () => { + setAccentThemeColor("#aa3311"); + applyThemeColorMeta("#101010"); + expect(metaContent()).toBe("#aa3311"); + }); + + it("clearing the accent restores the recorded background", () => { + applyThemeColorMeta("#101010"); + setAccentThemeColor("#aa3311"); + expect(metaContent()).toBe("#aa3311"); + setAccentThemeColor(null); + expect(metaContent()).toBe("#101010"); + }); +}); diff --git a/app/frontend/src/instance-accent.ts b/app/frontend/src/instance-accent.ts new file mode 100644 index 00000000..ab01f997 --- /dev/null +++ b/app/frontend/src/instance-accent.ts @@ -0,0 +1,127 @@ +import { + adjustBorderForContrast, + blendHex, + colorValueToHex, + BORDER_MIN_CONTRAST, +} from "@/themes"; +import type { Theme } from "@/themes"; + +/** + * Instance accent ("host color") primitives — pure helpers shared by the + * InstanceAccentProvider (contexts/instance-accent-context.tsx), the + * theme-context meta write, and the index.html pre-paint script's echo + * contract. + * + * The accent is a property of the INSTANCE (stored on its host in + * ~/.rk/settings.yaml `instance_color`), not the viewer — every device viewing + * the instance sees the same accent. Resolution order: explicit setting → + * localStorage echo (paint cache only, never authoritative) → hostname hash. + */ + +/** localStorage key echoing the resolved accent (paint cache only). The value + * is JSON `{"value": "", "hex": "#rrggbb"}` — `hex` is the final + * theme-color meta content so the index.html blocking script can tint the PWA + * titlebar before any fetch resolves. */ +export const INSTANCE_COLOR_STORAGE_KEY = "runkit-instance-color"; + +/** Ratio of the accent blended into the theme background for the top-bar wash + * (intake latitude: ~6-7%; one trivially-tunable constant). */ +export const INSTANCE_WASH_RATIO = 0.065; + +/** Zero-config identity default: hash the hostname onto one of the six + * standard ANSI hues — legacy descriptors "1".."6" (red/green/amber/blue/ + * magenta/teal via the owned-family mapping). FNV-1a 32-bit: tiny, stable + * across loads and devices (pure function of hostname). Returns null for an + * empty hostname (no accent rather than a misleading constant color). */ +export function hashHostnameColor(hostname: string): string | null { + if (!hostname) return null; + let h = 0x811c9dc5; + for (let i = 0; i < hostname.length; i++) { + h ^= hostname.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return String(1 + (h % 6)); +} + +export type InstanceColorEcho = { value: string; hex: string }; + +function isEcho(v: unknown): v is InstanceColorEcho { + if (typeof v !== "object" || v === null) return false; + if (!("value" in v) || !("hex" in v)) return false; + return typeof v.value === "string" && typeof v.hex === "string"; +} + +/** Read the echoed accent, tolerating absence, malformed JSON, and a wrong + * shape (all → null). Paint cache only — never authoritative. */ +export function readInstanceColorEcho(): InstanceColorEcho | null { + try { + const raw = localStorage.getItem(INSTANCE_COLOR_STORAGE_KEY); + if (raw == null) return null; + const parsed: unknown = JSON.parse(raw); + return isEcho(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** Write (or clear, with null) the accent echo. */ +export function writeInstanceColorEcho(echo: InstanceColorEcho | null): void { + try { + if (echo == null) { + localStorage.removeItem(INSTANCE_COLOR_STORAGE_KEY); + } else { + localStorage.setItem(INSTANCE_COLOR_STORAGE_KEY, JSON.stringify(echo)); + } + } catch { + // localStorage unavailable + } +} + +/** Theme-derived hexes for the accent surfaces: `stripeHex` is the + * contrast-guarded family hex (top-bar stripe, HOST hostname tint, and the + * theme-color meta content), `washHex` the subtle top-bar background blend. + * Accepts family names and legacy descriptors incl. blends ("1+3") via the + * owned-family mapping. Null when the value maps to no owned family. */ +export function deriveAccentHexes( + value: string, + theme: Theme, +): { stripeHex: string; washHex: string } | null { + const src = colorValueToHex(value, theme.palette); + if (src == null) return null; + const bg = theme.palette.background; + return { + stripeHex: adjustBorderForContrast(src, bg, theme.category === "dark", BORDER_MIN_CONTRAST), + washHex: blendHex(src, bg, INSTANCE_WASH_RATIO), + }; +} + +// ── Single theme-color meta writer ─────────────────────────────────────────── +// React passive effects run child-first: ThemeProvider's applyThemeToDOM effect +// fires AFTER any child accent effect on a theme switch and would clobber an +// accent-tinted meta tag with the bare background. Both writers therefore +// funnel through this module's shared state — last-write-wins over one content +// derivation (accent hex when an accent is set, else the theme background). + +let currentAccentHex: string | null = null; +let lastBackground: string | null = null; + +function writeMeta(): void { + const content = currentAccentHex ?? lastBackground; + if (content == null) return; + const tc = document.querySelector('meta[name="theme-color"]'); + if (tc) tc.setAttribute("content", content); +} + +/** Theme-side write: record the active theme background and re-apply. Called + * by theme-context's applyThemeToDOM on every theme application. */ +export function applyThemeColorMeta(background: string): void { + lastBackground = background; + writeMeta(); +} + +/** Accent-side write: record the accent meta hex (null = no accent) and + * re-apply. Called by the InstanceAccentProvider on accent/theme changes. */ +export function setAccentThemeColor(hex: string | null): void { + currentAccentHex = hex; + writeMeta(); +} diff --git a/docs/memory/run-kit/architecture.md b/docs/memory/run-kit/architecture.md index e17f885d..5ef63b8f 100644 --- a/docs/memory/run-kit/architecture.md +++ b/docs/memory/run-kit/architecture.md @@ -51,7 +51,7 @@ app/ tmux_config.go # POST /api/tmux/reload-config servers.go # GET /api/servers, POST /api/servers, POST /api/servers/kill keybindings.go # GET /api/keybindings (curated tmux keybindings via list-keys + whitelist) - settings.go # GET/POST /api/settings/theme, GET/POST /api/settings/server-color + settings.go # GET/POST /api/settings/theme, /server-color, /instance-color spa.go # SPA static serving — dual-mode (embedded FS or filesystem) frontend/ # Embedded frontend assets embed.go # //go:embed all:dist → embed.FS @@ -109,7 +109,7 @@ Packages in `app/backend/internal/`: | `internal/metrics` | Host-level system metrics collector reading Linux procfs. `Collector` struct with `sync.RWMutex`-protected `MetricsSnapshot`. `NewCollector(pollInterval)` creates collector, caches hostname via `os.Hostname()`, initializes 60-entry CPU ring buffer with zeros. `Start(ctx)` launches background poll goroutine (exits on `ctx.Done()`). `Snapshot()` returns a deep-copied `MetricsSnapshot` under `RLock`. Collects: CPU usage from `/proc/stat` (delta-based, ring buffer), memory from `/proc/meminfo` (`MemTotal` - `MemAvailable`), load from `/proc/loadavg`, disk via `syscall.Statfs("/")`, uptime from `/proc/uptime`. CPU core count from `/proc/stat` `cpu\d+` line count. All procfs readers return zero values on non-Linux (graceful degradation, no crash). `MetricsSnapshot` struct contains `Hostname`, `CPU` (samples `[]float64`, current, cores), `Memory` (used/total bytes), `Load` (avg1/5/15, cpus), `Disk` (used/total bytes), `UptimeSecs` | | `internal/ports` | In-memory **passive enumerator** of the host's **listening TCP ports** (ALL of them — HTTP or not), modeled on `internal/metrics.Collector` (`260701-5d0z-service-tiles-host-console`). **`260715-vfcz-passive-ports-process-attribution`)**: the collector connects to no port — it is purely observational. There is no HTTP probe: no `probe.go`, no verdict machinery (`probeTTL`, `probeEntry`, `probeCache`, `probePort`, `probeVerdicts`). **Why no probe**: a `GET /` to every newly-seen port *consumes* one-shot local servers on first contact — `gcloud`/`gh`/`aws` OAuth callback listeners handle exactly one request, so the probe broke routine CLI auth (there is no safe probing cadence — the FIRST probe is the killer); it also spammed dev-server access logs and could defeat idle-shutdown timers. The collector is now purely observational (Constitution §II — state is derived, never interacted with). `Collector` holds only a `sync.RWMutex`-protected `ServicesSnapshot` and a `pollInterval` (no probe cache, no clock seam). **`NewCollector(pollInterval)` seeds a non-nil zero-length slice** (`ServicesSnapshot{Services: []Service{}}`) so `Snapshot()` marshals to `{"services":[]}` (never `null`) if read before Start — There is no empty-seed filter to protect (no probe pass to seed against), so **`Start(ctx)` runs one synchronous `collect()` before launching the poll goroutine** — the SSE hub reads/broadcasts/caches `Snapshot()` on its very first poll pass (into `cachedServicesJSON`, replayed to every new client), which can run before the ticker first fires, so a synchronous seed means that first broadcast carries the real enumeration instead of a ~`pollInterval` "No services" gap. **Boot-delay tradeoff** (documented in `Start`'s doc): `Start` runs on the boot path (`api.NewRouterAndServer` calls it before `ListenAndServe` binds the socket), so the HTTP bind waits on this one synchronous enumeration; because both platforms now shell out to `lsof`, the bounded worst case is a single `lsofTimeout` (5s) if lsof hangs — a one-time startup cost, bounded by `exec.CommandContext`, deemed an acceptable price for a deterministic first SSE broadcast. `poll` still honors `ctx.Done()`; `Snapshot()` returns a slice-copied `ServicesSnapshot` under `RLock` (callers can't mutate the backing array). **`collect()` is now enumerate → sort → publish** (no `ctx` param — it existed only for probes): calls the `readListeningPortsFn` platform seam, sorts by port ascending, and publishes under `Lock`. No filtering, no probing. **Three-way `//go:build` platform split** behind the `readListeningPortsFn` package-var seam (defaults to the per-platform `readListeningPorts`, stubbable in platform-agnostic tests): **linux** (`collector_linux.go`) parses **`/proc/net/tcp` + `/proc/net/tcp6`** — selects LISTEN rows (`st` column == `0A`, `tcpStateListen`), extracts the hex local port from `HEXIP:HEXPORT` `local_address`, dedupes across v4/v6 into a `map[int]struct{}` — **procfs stays the AUTHORITATIVE port set** — then **JOINS best-effort lsof attribution onto it by port** (`lsofAttribution()`): ports lsof can attribute carry `Process`/`PID`, ports it cannot render bare (`:{port}` only). The join (not lsof-only enumeration) is **load-bearing**: a non-root `lsof` only sees the invoking user's processes on Linux, so an lsof-only enumeration would silently DROP root-owned listeners (`sshd :22`), violating the show-ALL-ports contract; procfs sees them regardless. lsof missing/failing degrades to bare procfs ports (attribution map empty; enumeration unaffected — zero-on-error). **darwin** (`collector_darwin.go`, since the `260702` macOS fix) has no procfs, so `lsof` is the SOLE enumeration source: `readListeningPorts` delegates to the shared `lsofAttribution()` and builds the port-sorted slice from its map (that map IS the enumeration, and yields `Process`/`PID` attribution for free); behavior unchanged from pre-`vfcz`. **other** (`collector_other.go`, tag `!linux && !darwin` — Windows/BSD) returns an empty slice (graceful zero). **Shared `lsof.go`** (build tag `//go:build linux || darwin`, extracted by `260715-vfcz` from the former darwin-only file) holds `lsofTimeout` (5s), the `lsofRun` package-var seam (default `exec.CommandContext(ctx, "lsof", "-nP", "-iTCP", "-sTCP:LISTEN", "-FpcPn")` — explicit argv, no shell string, no user input, Constitution §I), `parseLsof`/`parseLsofPort` (parse the `-F` field stream: `p`=pid, `c`=command, `n`=addr; port = decimal after last colon, handling `127.0.0.1:P` / `*:P` / `[::1]:P`; first process per port wins), and `lsofAttribution()` — the single run-lsof wrapper (bounded ctx → `lsofRun` → degrade to empty map on error-with-no-output, but a non-zero exit WITH partial stdout is still parsed → `parseLsof`) shared by both the Linux join and the darwin enumeration. `Service{Port int; Process string json:",omitempty"; PID int json:",omitempty"}` — `Process`/`PID` are **best-effort on BOTH platforms** (darwin from lsof; Linux from the lsof join where it can attribute, else bare). No `http:` boolean (there is no HTTP verdict anymore). `ServicesSnapshot{Services []Service}` is never nil (empty slice → JSON `[]`, not `null`) and now carries the semantic "**all** listening TCP ports". **No database/disk/tmux-option** — in-memory only (Constitution §II); the only subprocess is the bounded `lsof` (Constitution §I). Wired into the SSE hub in `api/router.go` (`svc := ports.NewCollector(servicesPollInterval); svc.Start(ctx)`, `servicesPollInterval = 2500ms` in `api/sse.go`, same cadence as metrics — wiring/payload shape unchanged by `vfcz`); broadcast as `event: services` — see § State Socket + § API Layer `/ws/state` | | `internal/prstatus` | In-memory, periodically-refreshed cache of the current user's recent PR statuses (OPEN + recently MERGED/CLOSED), modeled on `internal/metrics.Collector` (`260610-596o-pr-status-sidebar`). `Collector` holds `byNumber map[int]PRStatus` under a `sync.RWMutex` plus an `interval`. `NewCollector(interval)` (defaults `interval`, `ghExec`, `available` seams), `Start(ctx)` (runs `refresh` immediately to warm the cache, then ticks at `interval`; exits on `ctx.Done()`), `Snapshot()` (deep copy under `RLock` — callers read lock-free), `RefreshNow(ctx)` (on-demand, used by the POST endpoint; best-effort — errors swallowed), and the private `refresh(ctx)`. **One batched fetch**: `refresh` makes a SINGLE `gh api graphql` call querying `viewer.pullRequests(first: 100, states: [OPEN, MERGED, CLOSED], orderBy: UPDATED_AT DESC)` returning `number/url/state/isDraft/reviewDecision` + the latest commit's `statusCheckRollup.state` — one cross-repo call, O(1) in PR count. **Why include MERGED/CLOSED, not just OPEN**: a just-merged/closed PR is by definition recently updated, so it sits near the top of the `UPDATED_AT DESC` window and stays in the snapshot — letting the dot/line show `merged` (purple) or `closed` instead of dropping to a bare PR number the moment a PR lands. The merged state IS reachable and is the most useful signal on a change-bound window. The `first: 100` recency cap IS the eviction mechanism (below). Runs through the injectable `ghExec func(ctx) ([]byte, error)` field (default `defaultGhExec`) via `exec.CommandContext` + explicit arg slice (`gh api graphql -f query=… -F limit=100`) + 10s `ghTimeout` (no shell string, no user input in argv — Constitution §I). The intake's literal `gh search prs --author @me` was REJECTED at apply time (it errors — `gh search prs` does not expose `statusCheckRollup`/`reviewDecision` JSON fields); GraphQL `viewer.pullRequests` is the implemented form serving the same one-call/O(1)/cross-repo/full-status intent. **gh-availability guard**: the injectable `available func(ctx) bool` field (default `ghAvailable`, which checks `exec.LookPath("gh")` then `gh auth status`) gates the fetch — gh absent OR unauthenticated is a silent no-op (last-good map kept), matching the `command -v rk` fail-silent posture. **Wholesale REPLACE = cleanup**: a successful refresh builds a fresh `map[int]PRStatus` and replaces `byNumber` wholesale under `Lock`; the recency cap is the eviction — a merged/closed PR shows its terminal state while it stays near the top of the `UPDATED_AT DESC` window, then ages past the `first: 100` cap and drops from the next wholesale rebuild. This IS the cleanup mechanism, so there is no eviction logic or window-lifecycle hook. **Stale-while-revalidate**: a `gh` call error (network blip) or a JSON parse failure leaves the last-good map untouched (mirrors `metrics.Collector` / `fetchPaneMapCached` degradation). **Enum collapse** (pure helpers): `mapState(state, isDraft)` → `open\|merged\|closed` (`MERGED`→merged, `CLOSED`→closed, else open — draft surfaced separately via `IsDraft`); `mapChecks(rollupState)` → `pass\|fail\|pending\|none` (`SUCCESS`→pass, `FAILURE`/`ERROR`→fail, `PENDING`/`EXPECTED`→pending, ""/unknown→none — GitHub's `statusCheckRollup.state` is already a pre-collapsed enum, so no per-check iteration); `mapReview(decision)` → `approved\|changes_requested\|review_required\|none`. `PRStatus` struct: `Number int`, `URL string`, `State string`, `IsDraft bool`, `Checks string`, `ReviewDecision string`, `FetchedAt time.Time`. No database, no disk, no tmux option — in-memory only (Constitution §II). Wired into the SSE hub in `api/router.go`; cadence const `prStatusPollInterval = 90 * time.Second` lives in `api/sse.go`. **Branch→PR refresher (`prstatus_branch.go`, since `260705-dmex-generic-agent-state-tier`)**: a SEPARATE capability alongside the viewer-wide collector — `BranchRefresher` resolves a per-`(repoDir, branch)` PR via `gh pr list --head --state all --json number,url,state,updatedAt` (in the pane's repo via `cmd.Dir`; `exec.CommandContext` + `ghTimeout` + explicit argv, branch passed as a discrete arg — §I) and serves it from an in-memory snapshot. **All states, not just open** (`--state all`, since `260706-4h26-durable-merged-pr-register-keys`): a merged PR keeps resolving positive on every pass so its done-square is durable **statelessly** (no grace clock to expire, no negative-stamp retention, restart-proof — an rk restart re-derives it fresh from gh). The sessions hot path only **registers** observed pairs (`Register` — a cheap lock-guarded set touch) and **joins** via `Snapshot`/`SnapshotBranchPR` (NO exec); all `gh` subprocesses run on the refresher's background goroutine (`Start(ctx)` → immediate warm + `branchPRRefreshInterval = 30s` tick, mirroring `Collector.Start`), so the SSE hot path stays network-free. **On-demand seam** (`260715-jykd-manual-status-refresh`): the exported `RefreshNow(ctx)` delegates to the same private `refresh(ctx)` the tick runs (mirroring `Collector.RefreshNow`), re-resolving every registered pair on demand — best-effort per pair (a transient `gh` error keeps the last-good entry, exactly as the tick path behaves). Kicked by `POST /api/status/refresh` (§ API Endpoints) alongside the collector's `RefreshNow`. It gates on **one** availability check per pass — `checkAvailable` caches the `ghAvailable` verdict (positive AND negative, `branchPRAvailabilityTTL = 60s`) so an unauthenticated gh does not re-probe `gh auth status` per pair. A transient exec/network error **keeps the last-good entry** (true stale-while-revalidate — never fail-to-negative); a parsed malformed-JSON result likewise keeps last-good (`pickBranchPR` surfaces the parse error); a **successfully parsed empty/no-PR result clears the entry to a true negative** (`nil pr`, no retention — the grace machinery is gone). `pickBranchPR` selects by **precedence — open > merged > closed** (`branchStateRank`, state compared case-insensitively via `strings.ToUpper`; unknown/empty state sorts last), breaking ties **within a class** by most-recently-updated; a URL-less node is skipped (it can never key the live-status join), and an empty/all-skipped array returns `nil` (valid negative). Pairs no longer observed age out (`branchPRObservedTTL = 5m`). Exposed via the process-wide `DefaultBranchRefresher` (Started in `router.go` next to the collector) and the package-level `Register`/`SnapshotBranchPR` façade, so `FetchSessions`' signature is unchanged. `BranchPR` carries `Number`/`URL`/`State`/`UpdatedAt` (`State` re-added by `260706-4h26` to drive precedence — parsed from the branch query's `state` field, compared case-insensitively, not surfaced further; `IsDraft` stays trimmed — no consumer). See § Branch→PR Derivation | -| `internal/settings` | Global user settings persisted at `~/.rk/settings.yaml`. `Settings` struct with `Theme string`, `ServerColors map[string]string` (per-server color-value descriptors, since `260615-6rnr-expand-swatch-palette-blends` — was an int map), and `BoardOrder []string` (the user-defined board display order, rank = slice index, since `260708-a2qd-board-list-reorder`). `Default()` returns `Settings{Theme: "system", ...}`. `Load()` reads the settings file, returns `Default()` if missing — the nested `server_colors:` block is read **tolerantly**: each value passes through `validate.NormalizeColorValue` (accepts a legacy bare int OR the string descriptor, normalizes to the canonical string, drops malformed entries) so existing integer settings keep working with no migration step. `Save(s Settings)` writes the file (creating `~/.rk/`, mode 0755, if absent) and **always serializes colors as quoted strings** (`name: "1+3"`), server names sorted for stable output. `GetServerColor(server) *string` / `SetServerColor(server string, color *string) error` (nil clears the entry). **`BoardOrder` (since `260708-a2qd`)**: `GetBoardOrder() []string` returns the stored order (nil when never set / legacy file) and `SetBoardOrder(names []string) error` persists the FULL ordered list, replacing any prior order (nil/empty clears it) — both mirror `GetServerColor`/`SetServerColor` (load-then-save). The hand-rolled `parse` handles a `board_order:` heading followed by indented `- "name"` YAML sequence items (leading `- ` marker stripped, optional surrounding quotes stripped); `serialize` emits the `board_order:` block (quoted names) **only when the slice is non-empty**, so a theme-only settings file serializes **byte-identically** to the pre-change output (the exact-string `TestSaveAndLoad`/`TestSerialize` assertions are the hard constraint). A legacy file predating `board_order:` loads with `BoardOrder == nil` and no error. Uses simple `key: value` / one-level-nested text parsing (not yaml.v3) | +| `internal/settings` | Global user settings persisted at `~/.rk/settings.yaml`. `Settings` struct with `Theme string`, `InstanceColor string` (the per-instance accent "host color" — a scalar color-value descriptor, one per instance), `ServerColors map[string]string` (per-server color-value descriptors, since `260615-6rnr-expand-swatch-palette-blends` — was an int map), and `BoardOrder []string` (the user-defined board display order, rank = slice index, since `260708-a2qd-board-list-reorder`). `Default()` returns `Settings{Theme: "system", ...}`. `Load()` reads the settings file, returns `Default()` if missing — the nested `server_colors:` block is read **tolerantly**: each value passes through `validate.NormalizeColorValue` (accepts a legacy bare int OR the string descriptor, normalizes to the canonical string, drops malformed entries) so existing integer settings keep working with no migration step. `Save(s Settings)` writes the file (creating `~/.rk/`, mode 0755, if absent) and **always serializes colors as quoted strings** (`name: "1+3"`), server names sorted for stable output. `GetServerColor(server) *string` / `SetServerColor(server string, color *string) error` (nil clears the entry). **`InstanceColor` (since `260721-1etw-instance-accent-host-color`)**: the instance accent color, a scalar top-level key `instance_color` (unlike the `ServerColors` map) using the same descriptor format (`"4"` = single ANSI index, `"1+3"` = two-hue blend). Empty means "no explicit color" — the frontend then falls back to a hostname-hash default. Read is **tolerant** (`instance_color`'s value is quote-stripped then `validate.NormalizeColorValue`'d — a legacy bare integer normalizes, malformed drops); `serialize` emits `instance_color: ""` (always quoted, positioned after `theme_light` before `server_colors`) **only when non-empty**, so a file without an instance color serializes byte-identically to the pre-change output. `GetInstanceColor() *string` / `SetInstanceColor(color *string) error` mirror `GetServerColor`/`SetServerColor` (load-then-save; nil clears). **`BoardOrder` (since `260708-a2qd`)**: `GetBoardOrder() []string` returns the stored order (nil when never set / legacy file) and `SetBoardOrder(names []string) error` persists the FULL ordered list, replacing any prior order (nil/empty clears it) — both mirror `GetServerColor`/`SetServerColor` (load-then-save). The hand-rolled `parse` handles a `board_order:` heading followed by indented `- "name"` YAML sequence items (leading `- ` marker stripped, optional surrounding quotes stripped); `serialize` emits the `board_order:` block (quoted names) **only when the slice is non-empty**, so a theme-only settings file serializes **byte-identically** to the pre-change output (the exact-string `TestSaveAndLoad`/`TestSerialize` assertions are the hard constraint). A legacy file predating `board_order:` loads with `BoardOrder == nil` and no error. Uses simple `key: value` / one-level-nested text parsing (not yaml.v3) | | `internal/push` | Web Push subsystem — owns both `~/.rk/vapid.json` (VAPID keypair, `{public, private}` base64url, written mode **`0600`**, lazily generated on first need via `webpush.GenerateVAPIDKeys()` and reused on reload) and `~/.rk/push-subscriptions.json` (a JSON array of browser `PushSubscription`s, de-duped by `endpoint` — re-subscribe replaces, never appends). Both persist as filesystem state under `~/.rk/`, no database (Constitution §II), mirroring the `internal/settings` idiom. Tolerant load: a missing/empty/corrupt store is treated as an empty list, never surfaced as a read-path error. Exported surface: types `VAPIDKeys`, `Subscription`, `NotifyResult`; funcs `LoadOrCreateVAPIDKeys()`, `LoadSubscriptions()`/`SaveSubscriptions()`, `AddSubscription()`/`RemoveSubscriptions()`, and `Notify(ctx, title, body) (NotifyResult, error)`. `Notify` builds the `{title, body, icon}` JSON push payload (icon default `/generated-icons/icon-192.png`), fans out to every stored subscription via `webpush.SendNotificationWithContext` signed with the persisted VAPID **private** key under a bounded context, prunes any subscription the push service answers `404`/`410` (dead-endpoint cleanup — no separate unsubscribe endpoint in v1), and returns `{sent, pruned}` counts. Only the **public** key is ever served to a client; the private key never leaves the server. (`260615-xd9r-web-push-notifications`) | | `internal/updatecheck` | In-memory periodic checker that DELEGATES the update check to ONE exec of `shll check-updates --json` per pass and decides which shll-toolkit tools (run-kit + its siblings) have a newer release worth notifying about (`260713-4zap-update-notify-one-click-upgrade`; multi-tool verdict `260718-d15e-toolkit-manifest-update-notifications`; delegation + on-demand check `260720-n2ai-shll-check-updates-delegation-palette-checks`). No database/file — the verdict is ephemeral derived state (Constitution §II); the ONLY external surface is the single `shll check-updates --json` subprocess (Constitution §III — wrap, don't reinvent: the version fetch, brew-version join, and sibling threshold evaluation all live behind that one command). **The check exec is source-parameterized** via a closed enum (`SourceReleased = ""`, `SourceGithub = "github"`): the argv tail is built by `checkUpdatesArgs(source)` — the RELEASED path is flag-free (`released` is shll's default, so the invocation is valid across every shll version carrying `check-updates` and decoupled from shll's `--released`→`--source` flag consolidation), and `SourceGithub` appends the literal `"--source", "github"` pair (the source string is never spliced into the command — §I). **The ambient loop always runs `SourceReleased`**; only a manual `CheckNow` can request `SourceGithub`, which runs as a SIDE-CHANNEL query (see below). **JSON contract** (cross-repo, vendored as `testdata/check-updates.json`, `checkUpdatesSchema = 1`, unknown fields tolerated): `CheckReport{Schema int, Source string, Tools []CheckTool}` where `CheckTool{Name, Formula, Installed, Latest, Notify string, UpdateAvailable, Notable bool}` carries the per-tool verdict pre-evaluated by shll (`UpdateAvailable` = installed < latest; `Notable` = the bump crosses the tool's notify policy `never`/`patch`/`minor`, tuned shll-side). A tool is listed only when both installed and latest resolve. `New(current string, selfBrew bool) *Checker` normalizes the running version (`normalizeTag` strips a leading `v`) and, when it is the `devVersion = "dev"` sentinel OR does not parse as `X.Y.Z` (`parseMajorMinor` errors), **permanently suppresses** the checker (`Start` is a no-op, `Snapshot()` reports no update — a dev build never polls, which also keeps e2e runs off the toolchain). `Checker` holds `current`/`selfBrew`/`suppressed`, a `sync.RWMutex`-guarded `result Result`, `startCtx` (daemon ctx captured at `Start`, so a `RecheckAfter` timer dies with the process), a struct-FIELD `checkFn(ctx, source) (CheckReport, error)` seam (not a package var, so parallel tests don't race; default `defaultCheck` — `exec.LookPath("shll")` then `exec.CommandContext(ctx, shllPath, checkUpdatesArgs(source)...)` under `checkTimeout = 30s` (one subprocess wraps a network fetch plus brew reads → the constitution's 30s build-op tier), explicit argv no shell string per §I, then `json.Unmarshal` and a `Schema != 1` fail-closed gate), plus the exported `OnQualify func(Result)` callback (set before `Start`, wired in `serve.go` to `sseHub.broadcastUpdateAvailable`). `Result{Tools []ToolVerdict, Matched []ToolUpdate, Key string, Current, Latest string, Source string}`: `Source` echoes the report's self-identified backend (`CheckReport.Source` — `"released"`/`"github"`, `""` when a legacy stub carried none), propagated to the check response + SSE payload so the client reacts to what actually ran. `Tools` = the FULL per-tool verdict list (every tool with a pending update, INCLUDING sub-threshold `Notable:false` rows) in DETERMINISTIC sorted-name order; up-to-date tools are omitted (empty list = everything current). `Matched` = `notableSet(Tools)`, every `ToolUpdate{Tool, Installed, Latest}` whose bump is notable, same sorted order — the subset that drives the chip, the dismissal `Key`, and the scoped `shll update` argv. `Key` = the composite dismissal key, sorted `tool@latest` pairs comma-joined (`fab-kit@2.17.0,run-kit@3.9.0`, empty when nothing notable, via `computeKey` over `Matched` only); legacy `Current`/`Latest` populated from the run-kit row when run-kit is in the notable set (else empty — via `runKitFields`) for transitional not-yet-reloaded-frontend compat. `Start(ctx)` runs one check after `initialCheckDelay = 30s` then a fixed `checkInterval = 6h` `time.Ticker`, goroutine bound to `ctx`. `checkOnce(ctx, source)` execs `checkFn(ctx, source)` → `computeVerdicts` → `notableSet` → `computeKey`, building `newResult` (carrying `report.Source`). **For any non-released source it RETURNS `newResult` immediately — a SIDE-CHANNEL query: no cache write, no `OnQualify`.** This is load-bearing: the github contract carries no `notify`/`notable` fields, so every github row lands `notable=false` (`crossesThreshold` fail-closes on the empty notify string) — caching it would empty `Matched`, flip `Key` to `""`, and fire a spurious "cleared" `OnQualify` that wipes a legit released chip and replaces the cached SSE slot, and would 409 the scoped `shll update` (which reads `Snapshot().Matched`). On the RELEASED path only, `checkOnce` stores the verdict under `Lock` (capturing `prevKey` first), then **fires `OnQualify(newResult)` iff `key != prevKey`** — on ANY key change including to EMPTY (the "consumed match" clear: all previously-notable tools became current, a first-class event so the cleared verdict broadcasts + replaces the cached SSE slot and no reconnecting tab replays a stale match); an unchanged key (empty or not) never re-fires. `checkOnce` also RETURNS `(Result, error)` — the manual path surfaces the error, the ambient callers ignore it. **`computeVerdicts` trusts siblings VERBATIM** (their `update_available`/`notable` arrive pre-evaluated) and re-compares only the **run-kit row LOCALLY** against the RUNNING ldflags `current` (shll can only see the brew-visible version, which can legitimately lag the running process), producing BOTH verdicts — `UpdateAvailable = anyIncrease(current, latest)` and `Notable = crossesThreshold(current, latest, notify)` — and additionally requires `c.selfBrew` (a go-install/dev rk can't self-update through brew, so its own row is omitted entirely, not just un-notable). The minimal semver helpers (`normalizeTag`, `parseMajorMinor`, `parsePatch`, `crossesThreshold` (`never`→false, `minor`→`minorOrMajorIncrease` patch-never, `patch`→`anyIncrease`, unknown fail-closed to never, unparseable→false), `minorOrMajorIncrease`, `anyIncrease`) are retained SOLELY for this one local comparison and `New`'s suppression parse. **Failure posture is dual (`260720-n2ai`)**: when `shll` is not on PATH, exits non-zero (`1`=check failed, `2`=usage — any non-zero skips), or emits unparseable/wrong-schema JSON, `checkOnce` logs `slog.Warn` and **retains** the previous verdict (stale-while-revalidate) — the AMBIENT loop is fail-SILENT (never crashes, never surfaces to clients). The MANUAL seam is fail-LOUD: exported `CheckNow(ctx, source) (Result, error)` runs one inline pass and RETURNS the error so `POST /api/updates/check` can raise an honest toast; a suppressed checker returns an error without checking. `SourceReleased` is the loop's exact code path (cache write + OnQualify); `SourceGithub` is the side-channel query (`checkOnce` returns without touching the cache or firing). `Suppressed() bool` accessor lets the handler map suppression to its own status. **`RecheckAfter(d)`** (exported, R17): schedules ONE delayed re-check bound to `startCtx` via the `afterFuncFn` package-var seam (default `time.AfterFunc`, fire-and-forget) — the post-remediation trigger `api/update.go` calls after a scoped `shll update` so a consumed match propagates as a cleared verdict within minutes instead of waiting for the 6h tick; no-op on a suppressed checker or before `Start`. Test seams: `SetCheckForTest(fn func(source string) (CheckReport, error))` (Lock-guarded, wraps a context-free stub that RECEIVES the requested `source` so tests observe/branch on it, and clears `suppressed`), `SetSelfBrewForTest`, `CheckOnceForTest()` (synchronous `SourceReleased` `checkOnce`), and `SetRecheckHookForTest(fn func(time.Duration))` (replaces `RecheckAfter`'s scheduling with a delay-recording hook so the CROSS-PACKAGE `api/update` handler test asserts "spawn → re-check scheduled with the ~2min delay" without a real daemon ctx/timer; same-package checker tests drive the real path via `afterFuncFn`). Constructed + `Start`ed in `cmd/rk/serve.go` (needs the ldflags `main.version`, known only in `cmd/rk`), injected into `*api.Server` via `SetUpdateChecker`. **Cross-repo sequencing**: until a shll release carries `check-updates`, the not-on-PATH path keeps the checker silent (fail-silent-retain leaves the verdict empty → the chip stays hidden), exactly like a no-update steady state | | `internal/selfpath` | Tiny shared package resolving the running binary's own on-disk executable path and detecting a Homebrew install, extracted so `cmd/rk/upgrade.go` AND `api/update.go` share ONE brew-detection implementation that cannot drift (`260713-4zap-update-notify-one-click-upgrade`, cycle-1 rework — before extraction the resolver + marker were duplicated byte-for-byte across the two `package main`/`package api` entry points, an unreachable cross-package dup). Exported: `CellarMarker = "/Cellar/run-kit/"` const (the Cellar path segment identifying a brew-installed run-kit, e.g. `/opt/homebrew/Cellar/run-kit/0.5.3/bin/run-kit` — follows the `run-kit` formula name since `260709-gidk`), `Resolve() (string, error)` (`os.Executable` + `filepath.EvalSymlinks`, falling back to the raw `os.Executable` path on symlink error rather than erroring), `IsBrewInstalled(resolvedPath string) bool` (`strings.Contains(resolvedPath, CellarMarker)`). No seam variable lives here — the two consumers keep their own package-var seams (`upgrade.go`'s `resolveExeFn`, `update.go`'s `resolveSelfPathFn`) each defaulting to `selfpath.Resolve` | @@ -178,6 +178,7 @@ All endpoints served by the single Go binary on one port. POST-only mutations wi | `/api/settings/theme` | GET | Returns `{"theme": "..."}` — reads via `settings.Load()`. Returns `"system"` when no settings file exists. Not per-server (no `?server=` param) | | `/api/settings/theme` | POST | Accepts `{"theme": "..."}` — writes via `settings.Save()`. (Migrated PUT→POST by `260529-jad6` per §IX; body/response unchanged.) Returns `{"status": "ok"}` on success, `400` if theme is empty. Not per-server | | `/api/settings/server-color` | GET/POST | Per-server color preference, stored in `~/.rk/settings.yaml` `server_colors:` (string descriptors, `internal/settings`). `GET ?server=x` → `{"color": "1+3"\|null}`; bare `GET` → `{"colors": {server: descriptor}}`. `POST` accepts `{"server":"...","color":"4"\|"1+3"\|null}` — `handleSetServerColor` validates the descriptor via the shared `validate.ValidateColorValue` (`400` on malformed) then `settings.SetServerColor` (null clears). String descriptors since `260615-6rnr` (was an int `{"color":N}`; tolerant read keeps legacy int files working). Not per-`?server=` | +| `/api/settings/instance-color` | GET/POST | **Per-instance accent color ("host color")**, a SCALAR setting stored in `~/.rk/settings.yaml` `instance_color:` (`internal/settings`; `260721-1etw-instance-accent-host-color`). Mirrors `/api/settings/server-color` **minus the `server` key** — the accent is one-per-instance, not a map. `GET` → `{"color": "4"\|"1+3"\|null}` (`handleGetInstanceColor` returns the **explicit setting only** — the hostname-hash fallback is client-side, see ui-patterns.md § Instance Accent). `POST` accepts `{"color":"4"\|"1+3"\|null}` — `handleSetInstanceColor` validates a non-null descriptor via the shared `validate.ValidateColorValue` (`400` on malformed, nothing persisted) then `settings.SetInstanceColor` (null clears). Mutation is POST-only per §IX. Not per-`?server=` (the accent identifies the whole run-kit instance, stored authoritatively on its host so every viewing device sees the same accent — Constitution §II filesystem state, no database) | | `/api/boards` | GET | Lists pane boards summarized across every server returned by `tmux.ListServers(ctx)` (each board's pins are server-scoped; the list is the union of board names). Derived from `_rk-pin-*` session `@rk_board` vars (since `260602-qn62`). Returns `200 [{name, pinCount}]`; `[]` when empty (never `null`). Not per-server (no `?server=` query). **Rank-aware order (since `260708-a2qd-board-list-reorder`)**: `tmux.ListBoards` still returns alphabetical, but `handleBoardsList` then applies the user-defined display order at the API layer via the pure `sortBoardsByStoredOrder(boards, settings.GetBoardOrder())` helper (`internal/tmux` stays settings-unaware) — boards present in the stored order first, by their index in `~/.rk/settings.yaml` `board_order:`, then any board absent from it after them, alphabetically; stale names in the stored order (boards that no longer exist) match nothing and are ignored on read. **The response order IS the display order** for every consumer (sidebar BoardsSection, Host-page BOARDS zone, top-bar BoardSwitcher) — there is one list source, so this is the single sort choke point: NO `rank` field is added to the summary and NO client comparator is introduced (unlike servers, which have two list sources — see `/api/servers`). With no stored order the list stays alphabetical (unchanged behavior) | | `/api/boards/order` | POST | Persist the user-defined board display order — JSON body `{"order":["deploys","reviews",...]}` (the FULL ordered board-name list, since `260708-a2qd-board-list-reorder`). `handleBoardOrderPost` (`api/boards.go`) decodes with `DisallowUnknownFields` (`nil` order → `[]`), validates EACH name via `tmux.ValidBoardName` AND rejects a **duplicate** name — a malformed body, an invalid name, or a duplicate is `400` before any write — then persists the whole list via `settings.SetBoardOrder`. On success `initSSEHub()` + `sseHub.broadcastBoardOrder(order)` (server-global echo, see § SSE Hub) and returns `200 {"ok":true}`. POST per §IX; mirrors `handleServerOrderPost`. Every reorder replaces the full stored list, so stale names self-heal. Unlike `/api/boards/{name}/reorder` (a within-board PIN reorder via `@rk_board_order` fractional keys), this reorders the BOARDS THEMSELVES in the board list — persisted host-globally in settings, scoped to no tmux server | | `/api/boards/{name}` | GET | Returns the board's entries (one per `_rk-pin-*` session carrying this `@rk_board`) joined with live tmux window data: `[{server, windowId, session, windowIndex, windowName, orderKey, panes}]`, sorted by `orderKey`. **Response field shape unchanged** by `260602-qn62` — `session` is now the `_rk-pin-*` pin-session, transparent to the frontend. `{name}` validated against `^[A-Za-z0-9_-]{1,32}$` (`400` on invalid). A killed pinned window's session just drops out of the live read (no stale write-back). `200 []` when no entries match the name | @@ -239,6 +240,8 @@ The `POST /api/riff` + `GET /api/riff/presets` handlers surface the extracted `i | `getThemePreference()` | GET | `/api/settings/theme` | | `setThemePreference(theme)` | POST | `/api/settings/theme` (migrated PUT→POST by `260529-jad6`) | | `setServerColor(server, color)` | POST | `/api/settings/server-color` (migrated PUT→POST by `260529-jad6`) | +| `getInstanceColor()` | GET | `/api/settings/instance-color` — resolves `Promise` (the explicit descriptor, or `null` when unset — the frontend then falls back to the hostname-hash default). Server-independent (no `?server=`), uses `deduplicatedFetch`. `260721-1etw` | +| `setInstanceColor(color)` | POST | `/api/settings/instance-color` — `Promise`, body `{"color": string \| null}` (null clears); rejects on non-2xx via `throwOnError` so the caller can toast the `400`. Server-independent (no `?server=`), plain `fetch`. `260721-1etw` | | `setWindowColor(server, windowId, color)` | POST | delegates to `setWindowOptions` → `/api/windows/{windowId}/options` (`{"@color": color === null ? null : String(color)}`; since `260529-jad6`) | | `setSessionColor(session, color)` | POST | `/api/sessions/:session/color` | | `setSessionOrder(server, order)` | POST | `/api/sessions/order` (migrated PUT→POST by `260529-jad6`) | @@ -250,7 +253,7 @@ The `POST /api/riff` + `GET /api/riff/presets` handlers surface the extracted `i | `spawnRiff(server, session, opts?)` | POST | `/api/riff` (via `withServer`) — spawns a riff agent window in `session`'s repo. Signature changed by `260714-q9cg` to an **options object** `SpawnRiffOptions = {task?, preset?, where?, worktreeName?, tier?}` (was positional `task?, preset?`). Body is built by omitting each field when unset/**default** — `task`/`preset`/`worktreeName` when falsy, `where` when `"worktree"` (the backend default), `tier` when `"default"` (resolves the same tier as an omitted value) — so a **defaults-only call is byte-identical to the shipped two-field body**. `where` is the typed `RiffWhere = "worktree" | "checkout"` union. Resolves the parsed `RiffSpawnResult = {server, session, window, windowId}`. Throws via `throwOnError` on a non-ok response (a `400` carries a human-readable message, e.g. non-repo cwd). Consumed by the spawn-agent dialog (`260713-sbk1`, extended `260714-q9cg`) | | `getRiffPresets(server, session)` | GET | `/api/riff/presets?session=` (via `withServer` + `deduplicatedFetch`) — the dialog's **preflight**. Return type changed by `260714-q9cg` to `RiffPreflight = {presets: RiffPreset[]; tiers: string[]}` (was bare `RiffPreset[]`), tolerantly unwrapping the `{presets, tiers}` envelope (`presets ?? []`, `tiers ?? []`). `RiffPreset = {name, layout, paneCount}`. Throws on a non-ok response; the dialog treats a failure as "no presets, tier `default` only" and still allows a task-only spawn (`260713-sbk1`, extended `260714-q9cg`) | -All API functions (except `listServers`, `createServer`, `setServerOrder`, `killServer`, `getDirectories`, `getHealth`, `getThemePreference`, `setThemePreference`, `refreshStatus`, `triggerUpdate`, `triggerForceUpdate`, `triggerRestart`) append `?server={active}` via a module-level `setServerGetter()` mechanism. The `SessionProvider` sets the getter on mount. No multiplexed `action` field — each mutation is a separate function with its own URL path. +All API functions (except `listServers`, `createServer`, `setServerOrder`, `killServer`, `getDirectories`, `getHealth`, `getThemePreference`, `setThemePreference`, `getInstanceColor`, `setInstanceColor`, `refreshStatus`, `triggerUpdate`, `triggerForceUpdate`, `triggerRestart`) append `?server={active}` via a module-level `setServerGetter()` mechanism. The `SessionProvider` sets the getter on mount. No multiplexed `action` field — each mutation is a separate function with its own URL path. **Request deduplication**: A module-level `deduplicatedFetch` wrapper maintains a `Map>` of in-flight GET requests. When a GET to a URL already has an in-flight promise, the existing promise is returned (with `Response.clone()` so each caller can independently consume the body). POST/PUT requests always make fresh `fetch()` calls. Promises are cleaned up via `.finally()` on resolve or reject. All GET functions (`getHealth`, `getSessions`, `getDirectories`, `listServers`, `getKeybindings`, `getThemePreference`) use `deduplicatedFetch`; all mutation functions use plain `fetch`. Deduplication key is the full URL string (after `withServer()` appends `?server=`), so requests scoped to different tmux servers are not incorrectly merged. diff --git a/docs/memory/run-kit/index.md b/docs/memory/run-kit/index.md index 6e113279..93747ceb 100644 --- a/docs/memory/run-kit/index.md +++ b/docs/memory/run-kit/index.md @@ -13,4 +13,4 @@ description: "Web-based agent orchestration dashboard" | [rk-riff](rk-riff.md) | `rk riff` spawn engine — worktree + tmux window + Claude launcher with argv-ordered pane arrays, presets, named layouts, and parallel fan-out. Lives in internal/riff parameterized by explicit {server, session, repoRoot}, with two frontends: the CLI (flags/preconditions/derivation) and web-UI POST /api/riff. Covers spawn-shaping inputs (Where worktree|checkout, WorktreeName, Tier), launcher resolution via fab agent --print, fabconfig preset/tier reads, and exit-code + security discipline. | | [tmux-sessions](tmux-sessions.md) | Session enumeration and group filtering; direct-attach terminal relay over the muxed `/ws/terminals` socket (pin-session-first attach, session-scoped select); link-based board pin-sessions (`_rk-pin-*`, dual membership, last-link recovery); server-birth CWD pin ($HOME, fallback `/`) at birth-capable seams; window `@N` addressing; exact-match `=name:` targets; `MoveWindow` active-window preservation; folder auto-naming; SSE dead-server reap; test sockets + `rk reaper`; pane chat-send. | | [toolkit-standards](toolkit-standards.md) | run-kit's shll-toolkit-standards conformance posture — constitution binding (§ Toolkit Standards), audit-against-HEAD-build rule, per-standard status. help-dump, readme-extraction, skill, ten principles, update, version PASS. skill topic pages, `rk context` retired for `rk url`, Principle 9 `--quiet`/reaper caps, brew mutations SIGTERM-with-grace (`newBrewCmd`). install-composition Policy B (docs half) PASS — install docs centralized to shll.ai; Policy A binary half unaudited. | -| [ui-patterns](ui-patterns.md) | Frontend UI patterns: URL/route structure and canonical page names; persistent full-width top-bar chrome (PageType:name center heading, overflow-chevron menu, breadcrumb + hover-animation vocabulary); window-view lens model (tty/web/chat); boards view + pinning; sidebar (perf, keyboard nav, color, labels); status dot, PR registers, waiting surfacing; session tiles; spawn-agent dialog; update chip; terminal relay/font/theme; window-switch slide; mobile + keyboard support; optimistic UI. | +| [ui-patterns](ui-patterns.md) | Frontend UI patterns: URL/route structure and page names; persistent top-bar chrome (PageType:name heading, chevron menu, breadcrumb, hover vocabulary, instance-accent stripe/wash); window-view lens (tty/web/chat); boards view + pinning; sidebar (perf, keyboard nav, color, labels, HOST-panel accent picker); status dot, PR registers, waiting; session tiles; spawn dialog; update chip; terminal relay/font/theme; instance accent + PWA theme-color; mobile + keyboard; optimistic UI. | diff --git a/docs/memory/run-kit/ui-patterns.md b/docs/memory/run-kit/ui-patterns.md index b64ed7c9..30bdc913 100644 --- a/docs/memory/run-kit/ui-patterns.md +++ b/docs/memory/run-kit/ui-patterns.md @@ -1,5 +1,5 @@ --- -description: "Frontend UI patterns: URL/route structure and canonical page names; persistent full-width top-bar chrome (PageType:name center heading, overflow-chevron menu, breadcrumb + hover-animation vocabulary); window-view lens model (tty/web/chat); boards view + pinning; sidebar (perf, keyboard nav, color, labels); status dot, PR registers, waiting surfacing; session tiles; spawn-agent dialog; update chip; terminal relay/font/theme; window-switch slide; mobile + keyboard support; optimistic UI." +description: "Frontend UI patterns: URL/route structure and page names; persistent top-bar chrome (PageType:name heading, chevron menu, breadcrumb, hover vocabulary, instance-accent stripe/wash); window-view lens (tty/web/chat); boards view + pinning; sidebar (perf, keyboard nav, color, labels, HOST-panel accent picker); status dot, PR registers, waiting; session tiles; spawn dialog; update chip; terminal relay/font/theme; instance accent + PWA theme-color; mobile + keyboard; optimistic UI." type: memory --- # run-kit UI Patterns @@ -1434,6 +1434,22 @@ Selection is carried by tint **depth** alone (no left border — § Row Anatomy) **Storage**: stored color values are the **legacy descriptor** (`"4"` / `"1+3"`); frontend wire types are `WindowInfo.color?: string` and `ProjectSession.sessionColor?: string` (`src/types.ts`). Window and session colors are ephemeral tmux user options (`@color` per window, `@session_color` per session — survive session lifetime, not tmux-server restarts), set via `setWindowColor`/`setSessionColor` in `client.ts`. Server colors persist in `~/.rk/settings.yaml`. **No color storage/API/backend migration** — the owned-palette rewrite is frontend-only for color. See architecture.md § Data Model for the backend storage/validation contract. +### Instance Accent ("host color") + +A per-instance accent color makes multiple run-kit instances (laptop, Mac mini, GCP server) viewed as visually-identical installed Chrome PWA windows tell each other apart at a glance. It is an **axis orthogonal to the owned-palette color tinting above**: server/window/session colors saturate the sidebar and mean "which tmux server"; the instance accent means "which run-kit instance" and owns surfaces the sidebar colors never touch (the top bar and the HOST panel). The accent is a property of the **instance**, not the viewer — stored authoritatively on the instance's host (`~/.rk/settings.yaml` `instance_color`, see architecture.md § `internal/settings` and § `/api/settings/instance-color`), so every device viewing that instance sees the same accent. (`260721-1etw-instance-accent-host-color`) + +**Resolution chain** (frontend, in the `InstanceAccentProvider`): (1) the explicit `instance_color` setting from `getInstanceColor()` — authoritative; (2) the localStorage echo (`runkit-instance-color`) — a **paint cache only, never authoritative**, used as the first-frame seed before the fetches land; (3) a deterministic **hostname hash** — zero-config identity default (Constitution §VII). The hash (`hashHostnameColor` in `src/instance-accent.ts`) is FNV-1a 32-bit over the hostname `→ 1 + (h % 6)`, yielding legacy descriptors `"1".."6"` (the six owned families red/green/amber/blue/magenta/teal via `resolveFamily`); it is stable across loads and devices (pure function of hostname) and returns `null` for an empty hostname. Hostname comes from the existing `getHealth()` fetch. With 6 hues and few hosts, collisions are likely (~45% at 3 hosts) — which is why the manual picker matters. When the hostname is not yet known and nothing explicit/echoed exists, **no accent renders** (no crash, no flash of a wrong color). An `authoritative` flag (explicit resolved OR hostname known) gates clearing the echo/meta on a null accent, so the paint seed is never wiped before the real resolution replaces it. + +**Provider + consumers** — `InstanceAccentProvider` / `useInstanceAccent()` (`contexts/instance-accent-context.tsx`) mounts once in `RootWrapper` inside `ThemeProvider` > `ToastProvider` and above `ChromeProvider`, so it can read `useTheme()` for the theme-derived hexes and `useToast()` for the write-failure toast. It fetches `getInstanceColor()` + `getHealth()` once on mount (StrictMode-guarded via a `didFetchRef`), seeds the first paint from the echo, resolves per the chain, derives `{stripeHex, washHex}` from the active theme, keeps the echo + theme-color meta in sync on every accent/theme change, and exposes `{color, isExplicit, stripeHex, washHex, setColor}`. `setColor(descriptor | null)` updates state optimistically then POSTs via `setInstanceColor`, toasting on failure. Both rendering surfaces read from this ONE instance — one fetch, one state, so a pick in the HOST panel repaints the top-bar stripe without a reload. A test seam `InstanceAccentValueProvider` injects a fixed value (mirroring the session-context value-injection pattern). + +**Theme-derived hexes** (`deriveAccentHexes(value, theme)` in `src/instance-accent.ts`, never hardcoded): `stripeHex` is the **contrast-guarded** family hex — `colorValueToHex(value, palette)` then `adjustBorderForContrast(src, bg, isDark, BORDER_MIN_CONTRAST)` (the same guarded-hex derivation server tiles/stripes use, § Color Tinting) — used for the top-bar stripe, the HOST hostname tint, AND the theme-color meta content. `washHex` is a subtle `blendHex(src, bg, INSTANCE_WASH_RATIO = 0.065)` (~6.5%) accent-into-background blend for the top-bar wash. Blend descriptors (`"1+3"`) render through the same `resolveFamily`-based owned-family mapping as single indices — no new blend scheme. Both recompute on a theme switch; nothing hardcoded survives. + +**Top-bar stripe + wash** (`AppLayout`, `app.tsx`) — the persistent top bar's `shrink-0` wrapper (above `RootTopBar`) carries a 2px accent stripe (an `aria-hidden` `
`) plus the `washHex` as its `backgroundColor` (the `TopBar` header has no background of its own, so the wash on the wrapper shows through). Both render only when their hex is non-null (no accent resolved ⇒ neither renders). This is the "which instance" channel living where the sidebar colors do not. + +**HOST panel — accent hostname tint + swatch picker** (`components/sidebar/host-panel.tsx`) — the hostname in the panel's `headerRight` slot renders in `stripeHex` (the contrast-guarded accent) when resolved, else the default `text-text-primary`. A palette swatch button rides the `CollapsiblePanel` **`headerAction` slot** (a sibling of the toggle button — a nested button would be invalid markup; the `headerAction` seam's clicks don't toggle the panel), hover-revealed with the touch/keyboard fallbacks `opacity-0 group-hover/panel:opacity-100 coarse:opacity-100 focus-visible:opacity-100` (`CollapsiblePanel`'s header gained a `group/panel` class to drive it) and an `aria-label="Set instance color"`. Clicking it opens a **color-only** `SwatchPopover` (no `onSelectMarker`) portalled to `document.body` at fixed coordinates anchored at the button — right-aligned, with a **flip-above** `useLayoutEffect` heuristic (the HOST panel sits at the sidebar bottom, so flip-above is the norm) — escaping the panel's overflow clip. This is the x4sf ServerGroup-header picker precedent (§ Server-group header action cluster). A pick calls `useInstanceAccent().setColor` (which POSTs and repaints every surface); `Clear color` sends `null`, restoring the hash default. + +**PWA titlebar bridge — single theme-color meta writer** (`src/instance-accent.ts`) — the `` tag is accent-aware through ONE shared writer holding module-level state (`currentAccentHex` + `lastBackground`, content = accent hex when set, else the theme background): (a) the blocking pre-paint script in `index.html` (§ PWA Meta Tags & Theme Color) reads the echoed `hex` from `runkit-instance-color`, validates the `#rrggbb` shape, and applies it as the initial theme-color (falling back to the per-mode defaults) so an installed PWA window opens already tinted with no flash; (b) at runtime the provider calls `setAccentThemeColor(stripeHex)` on accent/theme change; (c) `theme-context.tsx`'s `applyThemeToDOM` calls `applyThemeColorMeta(theme.palette.background)` instead of writing the meta directly, so a theme switch cannot clobber the accent tint. Desktop Chrome retints installed-PWA titlebars live on meta changes. **Out of scope** (follow-up): dynamically-served `manifest.json`, tinted manifest/dock icons, the Badging API — dock icons snapshot at PWA install time and refresh only on Chrome's lazy manifest-update cycle (an accepted, documented limitation); this change lays only the settings.yaml groundwork they need. + ### Row Anatomy — Selection & Board-Pin Cues (`window-row.tsx`) The window row's visual channels are orthogonal: **hue = label** (family tint), **tint depth = selection**, **left-edge zone = the independent marker axis + the Label-picker trigger** (§ Left-Edge Label Zone). Selection and the board-pin cue are borderless — the sidebar window row carries no left accent border. @@ -1798,7 +1814,7 @@ Preference persisted to backend API (`PUT /api/settings/theme` → `~/.rk/settin ### No-Flicker Initialization -A blocking inline `