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..3e70a11a 100644
--- a/app/frontend/src/components/sidebar/collapsible-panel.tsx
+++ b/app/frontend/src/components/sidebar/collapsible-panel.tsx
@@ -10,6 +10,13 @@ type CollapsiblePanelProps = {
headerRight?: React.ReactNode;
/** Action element rendered at the right side of the header (e.g. "+" button). Click events are stopped from toggling the panel. */
headerAction?: React.ReactNode;
+ /** Action element rendered immediately after the title (e.g. the Host panel's
+ * instance-color picker). A button here must be a SIBLING of the toggle
+ * button — nesting would be invalid markup — so when this is set the header
+ * splits into [toggle: chevron+title][titleAction][toggle region: headerRight].
+ * The trailing region still toggles on click (mouse parity with the default
+ * single-button layout); keyboard toggling stays on the title button. */
+ titleAction?: React.ReactNode;
/** Override the default content padding classes. */
contentClassName?: string;
/** Called after the panel is toggled. */
@@ -92,6 +99,7 @@ export function CollapsiblePanel({
defaultOpen = true,
headerRight,
headerAction,
+ titleAction,
contentClassName,
onToggle,
tint,
@@ -266,14 +274,14 @@ 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}
>
+ {titleAction}
+ {titleAction && (
+ /* Split layout (titleAction set): the trailing region keeps the
+ default layout's click-to-toggle for the mouse; keyboard toggling
+ lives on the title button, so this is a convenience duplicate, not
+ the primary control. Same no-`truncate` rule as above. */
+
+ {headerRight && (
+
+ {headerRight}
+
+ )}
+
+ )}
{headerAction}
diff --git a/app/frontend/src/components/sidebar/host-panel.test.tsx b/app/frontend/src/components/sidebar/host-panel.test.tsx
index 6e0d9201..96f8b5a1 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,47 @@ 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();
});
@@ -63,11 +108,70 @@ describe("HostPanel metrics fallback (260720-zx4i)", () => {
expect(screen.getByText("No metrics")).toBeInTheDocument();
});
- it("dot reflects the isConnected prop in the fallback state too", () => {
- renderPanel({ server: null, host: snapshot("h"), isConnected: true });
- expect(screen.getByTitle("SSE connected")).toBeInTheDocument();
- cleanup();
- renderPanel({ server: null, host: snapshot("h"), isConnected: false });
- expect(screen.getByTitle("SSE disconnected")).toBeInTheDocument();
+ it("renders no connection dot — the top-bar dot owns that signal", () => {
+ renderPanel({ server: null, host: snapshot("h") });
+ expect(screen.queryByTitle(/connected/i)).not.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..235e18aa 100644
--- a/app/frontend/src/components/sidebar/host-panel.tsx
+++ b/app/frontend/src/components/sidebar/host-panel.tsx
@@ -1,15 +1,13 @@
+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
- * subscription on server routes, the host-metrics source on the board route
- * (where no server-scoped signal exists) — derived by `BottomPanels`. */
- isConnected: boolean;
-};
-
-export function HostPanel({ isConnected }: HostPanelProps) {
+export function HostPanel() {
// Server-scoped metrics win when present; fall back to the host-global
// metrics broadcast (available on EVERY route) when they are null — the
// board route has no `currentServer`, so the server-scoped slice is null by
@@ -18,20 +16,101 @@ 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; bottom?: number; left: number } | null>(null);
+ // Portal the popover to document.body at fixed coordinates anchored at the
+ // palette button (left-aligned since the button now sits beside the title,
+ // 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 = 110; // color-only grid — used ONLY to pick the branch
+ const approxPopoverWidth = 170; // 4 swatch columns + padding
+ const below = rect.bottom + 4;
+ const fitsBelow = below + approxPopoverHeight <= window.innerHeight;
+ const left = Math.max(4, Math.min(rect.left, window.innerWidth - approxPopoverWidth - 4));
+ // Below-placement anchors the popover's top under the button (exact).
+ // Flip-above anchors its BOTTOM edge just over the button via CSS `bottom`,
+ // so the placement is exact regardless of the popover's real height — an
+ // estimated `top` would leave a gap whenever the estimate overshoots.
+ setPopoverPos(
+ fitsBelow
+ ? { top: below, left }
+ : { bottom: window.innerHeight - rect.top + 4, left },
+ );
+ }, [showColorPicker]);
+
+ // No connection dot here: the top-bar dot already reflects the same
+ // current-server subscription health (session-context wires
+ // `setChromeConnected(slice.isConnected)`), and the old "SSE" wording
+ // predated the /ws/state socket.
const hostnameHeader = metrics ? (
+
+ {metrics.hostname}
+
+ ) : null;
+
+ // Sibling of the header toggle button (the `titleAction` slot, right of the
+ // HOST title) — a button nested inside the toggle would be invalid markup.
+ // Hover-revealed with the touch/keyboard fallbacks (the session-row
+ // convention).
+ const paletteAction = (
<>
- {metrics.hostname}
-
+
+ {showColorPicker && popoverPos && createPortal(
+
+ );
+}
+
+function renderProvider() {
+ return render(
+
+
+
+
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ mockMatchMedia();
+ document.head.innerHTML = '';
+ vi.mocked(getInstanceColor).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 localStorage echo", async () => {
+ writeInstanceColorEcho({ value: "2", hex: "#00ff00" });
+ vi.mocked(getInstanceColor).mockResolvedValue("5");
+
+ 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("defaults to no accent when no explicit color is set (no derived default)", async () => {
+ vi.mocked(getInstanceColor).mockResolvedValue(null);
+
+ renderProvider();
+ await waitFor(() => expect(screen.getByTestId("color").textContent).toBe("null"));
+ expect(screen.getByTestId("explicit").textContent).toBe("false");
+ expect(screen.getByTestId("stripe").textContent).toBe("null");
+ expect(screen.getByTestId("wash").textContent).toBe("null");
+ });
+
+ it("seeds the first paint from the echo while the fetch is pending", async () => {
+ writeInstanceColorEcho({ value: "3", hex: "#aabb00" });
+ vi.mocked(getInstanceColor).mockReturnValue(new Promise(() => {}));
+
+ renderProvider();
+ expect(screen.getByTestId("color").textContent).toBe("3");
+ expect(screen.getByTestId("explicit").textContent).toBe("false");
+ });
+
+ it("clears a stale echo once the fetch resolves to no explicit color", async () => {
+ writeInstanceColorEcho({ value: "3", hex: "#aabb00" });
+ vi.mocked(getInstanceColor).mockResolvedValue(null);
+
+ 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);
+
+ renderProvider();
+ await waitFor(() => expect(screen.getByTestId("explicit").textContent).toBe("false"));
+
+ 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 removes the accent entirely without reload", async () => {
+ vi.mocked(getInstanceColor).mockResolvedValue("5");
+
+ renderProvider();
+ await waitFor(() => expect(screen.getByTestId("color").textContent).toBe("5"));
+
+ fireEvent.click(screen.getByText("clear"));
+ await waitFor(() => expect(screen.getByTestId("color").textContent).toBe("null"));
+ expect(screen.getByTestId("explicit").textContent).toBe("false");
+ expect(screen.getByTestId("stripe").textContent).toBe("null");
+ expect(setInstanceColor).toHaveBeenCalledWith(null);
+ await waitFor(() => expect(readInstanceColorEcho()).toBeNull());
+ });
+});
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..f065bd34
--- /dev/null
+++ b/app/frontend/src/contexts/instance-accent-context.tsx
@@ -0,0 +1,128 @@
+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 } from "@/api/client";
+import {
+ 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 → none; there is no derived default), 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 is set
+ * (the default — a fresh instance has no color until the user picks one). */
+ color: string | null;
+ /** True when an explicit instance color is set. */
+ 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 → no accent) 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);
+ // Pre-fetch paint seed from the last load's echo (paint cache only — the
+ // authoritative resolution below overwrites it as soon as the fetch lands).
+ const [echoSeed] = useState(() => readInstanceColorEcho()?.value ?? null);
+
+ // Fetch the explicit setting 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));
+ }, []);
+
+ // Resolution chain: explicit setting → (while pending) echo seed → none.
+ // There is no derived default — an unset instance renders no accent.
+ // `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 = explicit === undefined ? echoSeed : explicit;
+ const authoritative = explicit !== undefined;
+
+ 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..57fb7e02
--- /dev/null
+++ b/app/frontend/src/instance-accent.test.ts
@@ -0,0 +1,97 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import {
+ 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("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..80395031
--- /dev/null
+++ b/app/frontend/src/instance-accent.ts
@@ -0,0 +1,114 @@
+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) → none. There is
+ * no derived default: a fresh instance starts with no accent until the user
+ * picks one.
+ */
+
+/** 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;
+
+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/app/frontend/tests/e2e/sidebar-panels.spec.md b/app/frontend/tests/e2e/sidebar-panels.spec.md
index 7beb40fa..c41d99e0 100644
--- a/app/frontend/tests/e2e/sidebar-panels.spec.md
+++ b/app/frontend/tests/e2e/sidebar-panels.spec.md
@@ -86,8 +86,9 @@ reload.
param and both bottom panels used to render empty by construction — the PANE
panel follows the board's focused tile (resolving the pinned window's
enriched home-session copy by `windowId` from the sessions stream) and the
-HOST panel falls back to the host-global metrics broadcast, with its dot
-reflecting host-metrics health (260720-zx4i).
+HOST panel falls back to the host-global metrics broadcast (260720-zx4i).
+The HOST header carries no connection dot — the top-bar dot owns that
+signal (260721-1etw).
**Steps:**
1. Resolve the test session's window id via `tmux list-windows` and pin it
@@ -98,8 +99,8 @@ reflecting host-metrics health (260720-zx4i).
`No window selected` is absent — the focused-tile fallback filled the
panel.
4. Locate the Host outer panel and assert `cpu` (within 8s, first metrics
- tick) and `mem` rows render, `No metrics` is absent, and the header dot
- carries `title="SSE connected"` (host-metrics health source).
+ tick) and `mem` rows render, `No metrics` is absent, and no element with
+ an `SSE` title exists (the header connection dot was removed).
5. `finally`: unpin the window via the API so the shared server carries no
leftover board.
diff --git a/app/frontend/tests/e2e/sidebar-panels.spec.ts b/app/frontend/tests/e2e/sidebar-panels.spec.ts
index e9943a6a..b3383c42 100644
--- a/app/frontend/tests/e2e/sidebar-panels.spec.ts
+++ b/app/frontend/tests/e2e/sidebar-panels.spec.ts
@@ -178,15 +178,16 @@ test.describe("Sidebar Host & Window Panels", () => {
await expect(panePanel.locator("text=No window selected")).not.toBeVisible();
// HOST panel — no currentServer on the board route, so the panel falls
- // back to the host-global metrics broadcast (and the dot to host-metrics
- // health) instead of "No metrics".
+ // back to the host-global metrics broadcast instead of "No metrics".
+ // The header carries no connection dot (the top-bar dot owns that
+ // signal), so its absence is asserted rather than an "SSE" title.
const hostButton = page.getByRole("button", { name: /^Host/ });
await expect(hostButton).toBeVisible();
const hostPanel = hostButton.locator("../..");
await expect(hostPanel.locator("text=cpu")).toBeVisible({ timeout: 8_000 });
await expect(hostPanel.locator("text=mem")).toBeVisible();
await expect(hostPanel.locator("text=No metrics")).not.toBeVisible();
- await expect(hostPanel.locator("[title='SSE connected']")).toBeVisible();
+ await expect(hostPanel.locator("[title*='SSE']")).toHaveCount(0);
} finally {
// Unpin so the shared server carries no leftover board (empty boards are
// reaped server-side).
diff --git a/docs/memory/run-kit/architecture.md b/docs/memory/run-kit/architecture.md
index e17f885d..d6ec254a 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 color set" — the frontend renders no accent (there is no derived 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** — `null` means no accent renders; there is no derived default, 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 — no accent renders; there is no derived 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..9be82016 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
@@ -1302,7 +1302,7 @@ The session/window tree is a **W3C-APG two-level disclosure tree** with roving-t
Two collapsible panels are pinned at the bottom of the sidebar below the scrollable session tree, above the server selector. Layout order top-to-bottom: server selector -> session list (`flex-1 overflow-y-auto`) -> Window panel -> Host panel. Combined height target ~140px when both open.
-**CollapsiblePanel** (`app/frontend/src/components/sidebar/collapsible-panel.tsx`) — reusable wrapper used by both Window and Host panels. Props: `title` (string), `storageKey` (string for localStorage persistence), `defaultOpen` (boolean, default `true`), `children` (ReactNode), `headerRight` (ReactNode rendered inside the toggle button, right-aligned — the StatusDot+name slot; clicking it toggles the panel), and `headerAction` (ReactNode rendered OUTSIDE the toggle button at the header's right edge, whose clicks are **stopped from toggling the panel** — the purpose-built seam for an interactive control like the PANE-header refresh button, see WindowPanel below). Header is always visible: the title (`TypedLabel` — the section-label typed-sweep hover treatment, `260703-5ilm`, see § Hover-Animation Vocabulary) + chevron (`▸` U+25B8) that rotates 90 degrees on toggle via CSS `transform: rotate()` with `transition-transform duration-150`. Content area uses `max-height` transition (`duration-150 ease-in-out`) for smooth expand/collapse. `overflow: hidden` during transition, `visible` when fully expanded (accessibility). Collapse state persisted to `localStorage[storageKey]` on every toggle. Each panel has `border-t-[3px] border-border first:border-t-0` (a 3px structural seam — see § Border-Width System — so panels partition cleanly; the resizable drag handle is transparent by default, letting the next panel's 3px top border be the sole visible inter-panel line).
+**CollapsiblePanel** (`app/frontend/src/components/sidebar/collapsible-panel.tsx`) — reusable wrapper used by both Window and Host panels. Props: `title` (string), `storageKey` (string for localStorage persistence), `defaultOpen` (boolean, default `true`), `children` (ReactNode), `headerRight` (ReactNode rendered inside the toggle button, right-aligned — the StatusDot+name slot; clicking it toggles the panel), `headerAction` (ReactNode rendered OUTSIDE the toggle button at the header's right edge, whose clicks are **stopped from toggling the panel** — the purpose-built seam for an interactive control like the PANE-header refresh button, see WindowPanel below), and `titleAction` (ReactNode rendered OUTSIDE the toggle button immediately AFTER the title — when set, the header splits into `[toggle: chevron+title][titleAction][toggle click-region: headerRight]` so the trailing region keeps mouse click-to-toggle; the HOST panel's instance-color picker rides this seam, see § Instance Accent). Header is always visible: the title (`TypedLabel` — the section-label typed-sweep hover treatment, `260703-5ilm`, see § Hover-Animation Vocabulary) + chevron (`▸` U+25B8) that rotates 90 degrees on toggle via CSS `transform: rotate()` with `transition-transform duration-150`. Content area uses `max-height` transition (`duration-150 ease-in-out`) for smooth expand/collapse. `overflow: hidden` during transition, `visible` when fully expanded (accessibility). Collapse state persisted to `localStorage[storageKey]` on every toggle. Each panel has `border-t-[3px] border-border first:border-t-0` (a 3px structural seam — see § Border-Width System — so panels partition cleanly; the resizable drag handle is transparent by default, letting the next panel's 3px top border be the sole visible inter-panel line).
**WindowPanel** (`app/frontend/src/components/sidebar/status-panel.tsx`) — collapsible panel with `title="Pane"`, `storageKey="runkit-panel-window"`, `defaultOpen={true}`. **PANE-header refresh button** (`PaneRefreshButton`, with an `idle | spinning | check` feedback state machine): passed as `CollapsiblePanel`'s `headerAction` (NOT `headerRight` — `headerAction` is the seam whose clicks are stopped from toggling the panel; `headerRight` is the StatusDot+name slot inside the toggle button). It kicks a server-side on-demand refresh of BOTH PR pollers via `refreshStatus()` → `POST /api/status/refresh` (see architecture.md § API Endpoints). **Feedback state machine**: a single `useState<"idle" | "spinning" | "check">` (discriminated over the three mutually-exclusive visuals; consumed by tests via a `data-state` attribute). The button **spins from CLICK until the server-global `status-refresh` SSE completion event, NOT until the POST settles** (the POST 202s in ~ms while the real `gh` work runs 1–10s detached). It branches on the tri-state 202 body: `started`/`coalesced` → `spinning` (no distinct coalesced visual — the in-flight pass's one completion event clears both); `throttled` → skip spinning and `flashCheck()` immediately ("already fresh"; no event will come). Completion is delivered via `useSessionContext().subscribeStatusRefresh` (subscribed ONLY while `spinning`, so an unrelated tab's completion doesn't flash an idle button); on fire it transitions `spinning → check` (a brief post-completion checkmark, `REFRESH_CHECK_MS = 1000`, auto-reverting to `idle`, mirroring the same-file `COPY_FEEDBACK_MS` cadence). A **15s UI fallback** (`REFRESH_FALLBACK_MS = 15000`, well under the 60s `statusRefreshTimeout` backend bound) clears a stuck spinner if the event is missed — **armed at click ENTRY, not inside `.then`** (a started/coalesced completion event can beat the POST settle, so arming in `.then` would restart a timer `flashCheck()` already cleared → a phantom checkmark ~15s later). A `refreshStatus()` rejection (non-2xx via the shared `throwOnError`) is swallowed back to `idle` (clearing the fallback) so a network failure never leaves a stuck spinner. All timers are cleared on every transition and on unmount. Renders `animate-spin` on the `lucide` rotate-cw SVG in `spinning`, a checkmark SVG (`data-testid="pane-refresh-check"`, `text-accent`) in `check`, the static rotate-cw icon in `idle`; 14×14 SVGs, 24/30px touch target (`coarse:`), `data-testid="pane-refresh"`, `data-state`, `aria-label`/`title="Refresh PR status"`. Follows the top-bar/board `RefreshButton` **CRT-glint** vocabulary (`rk-glint` — § Hover-Animation Vocabulary). It **renders whenever the PANE header renders, including with no window selected** — the refresh is server-global (both pollers are viewer-wide), so gating on window selection would add nothing. Best-effort/fire-and-forget: server-side coalescing + a min-interval throttle make it safe to over-fire (the fallback still clears any spinner). Scope-honest: labeled around PR/status freshness (the other PANE registers out/agt/fab are already fresh within ~7.5s). The `subscribeStatusRefresh` context seam it consumes is a `Set`-of-handlers ref + `fireStatusRefresh` fired from BOTH the per-server pool ES block AND the `?metrics=1` metrics-stream ES block in `session-context.tsx`, mirroring `subscribeBoardOrder`/`fireBoardOrder` (the event is host-global, so both streams carry it). Palette parity: the `PR: Refresh Status` action (§ Command Palette `PR: Refresh Status` action) is unchanged — a pure POST kick with no shared button state to hook. The `headerRight` slot renders ` {win.name}` (in a `flex items-center gap-1.5 truncate text-text-secondary font-mono` span) — the unified status dot (see § Status Dot) leads the header so the window's status (palette-v3 two-family ladder + waiting halo) stays glanceable even when the panel is collapsed; hovering/focusing the dot opens the custom `StatusDotTip` hover-card (§ Status Dot). Displays the identity rows `tmx` (pane index + ID), `cwd` (shortened path), `git` (branch), then the **four orthogonal signal registers** `out` (L0 activity + elapsed) / `agt` (L1 `agentState` + duration) / `fab` (L2 change · stage · displayState) / `PR` (L3 derived PR, ungated) — the 3-char register view (§ Pane panel four-register view). The L1 `agt` register (`getAgentLine`) is `null` when `!win.agentState`, else `${agentState} ${agentIdleDuration}`; a blocked pane shows `waiting ` (the pierce rule: it shows independently of the flowing-output L0 register). No window selected -> "No window selected" in secondary text. `StatusPanel` is exported as a deprecated alias. (`260715-jykd`, `260715-nwla`, `260616-37ub`, `260706-y1ar`, `260706-4h26`)
@@ -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 fetch lands; (3) **none** — there is **no derived default**: a fresh instance renders no accent until the user picks one (an earlier hostname-hash default was removed — its color could coincide with a just-cleared explicit pick, making "Clear color" look like a no-op). An `authoritative` flag (the explicit fetch resolved) 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` **`titleAction` slot** (immediately right of the HOST title — a sibling of the toggle button, since a nested button would be invalid markup; with `titleAction` set the header splits into `[toggle: chevron+title][titleAction][toggle click-region: headerRight]`, the trailing region keeping mouse click-to-toggle while keyboard toggling stays on the title button), 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 — left-aligned (the button sits beside the title at the sidebar's left edge), 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`, removing the accent entirely (the no-color default). The HOST header carries **no connection dot**: the top-bar dot already reflects the same current-server subscription health (`setChromeConnected(slice.isConnected)` in session-context), and the removed dot's "SSE" title predated the `/ws/state` socket.
+
+**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 `