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( +
+ { + setColor(c); + setShowColorPicker(false); + }} + onClose={() => setShowColorPicker(false)} + /> +
, + document.body, + )} - ) : null; + ); return ( - + {!metrics ? (
No metrics
) : ( diff --git a/app/frontend/src/components/sidebar/index.test.tsx b/app/frontend/src/components/sidebar/index.test.tsx index a1fefda0..849a558b 100644 --- a/app/frontend/src/components/sidebar/index.test.tsx +++ b/app/frontend/src/components/sidebar/index.test.tsx @@ -6,6 +6,7 @@ import { OptimisticProvider } from "@/contexts/optimistic-context"; import { HostMetricsProvider, MetricsProvider, StandaloneSessionContextProvider } from "@/contexts/session-context"; import { FocusedPaneProvider, useRegisterFocusedPane, type FocusedPane } from "@/contexts/focused-pane-context"; import { ThemeProvider } from "@/contexts/theme-context"; +import { InstanceAccentValueProvider, type InstanceAccent } from "@/contexts/instance-accent-context"; import { ChromeProvider } from "@/contexts/chrome-context"; import { ToastProvider } from "@/components/toast"; import { useWindowStore } from "@/store/window-store"; @@ -18,6 +19,17 @@ import { } from "@/themes"; import type { MetricsSnapshot, ProjectSession } from "@/types"; +// HostPanel (inside Sidebar) consumes the instance-accent context; inject a +// static null accent so sidebar tests need no fetching provider (1etw). +const NULL_ACCENT: InstanceAccent = { + color: null, + isExplicit: false, + stripeHex: null, + washHex: null, + setColor: () => {}, +}; + + const mockNavigate = vi.fn(); vi.mock("@tanstack/react-router", () => ({ useNavigate: () => mockNavigate, @@ -106,6 +118,7 @@ function renderSidebar(opts: RenderOpts = {}) { ); const tree = ( + + ); return render(opts.strict ? {tree} : tree); @@ -425,6 +439,7 @@ describe("Sidebar — tree ARIA + roving keyboard navigation (wt1v)", () => { const sessionsByServer = new Map([["primary", sessions]]); return ( + { + ); } @@ -916,7 +932,7 @@ describe("BottomPanels — board-route focused-pane fallback + HOST dot (zx4i)", uptime: 60, }; - it("HOST dot follows hostMetricsConnected when currentServer is null (board route)", () => { + it("HOST panel fills from the host-global broadcast on the board route, with no connection dot", () => { renderSidebar({ currentServer: null, servers: boardServers, @@ -926,23 +942,11 @@ describe("BottomPanels — board-route focused-pane fallback + HOST dot (zx4i)", hostMetricsConnected: true, }); // The host-global fallback fills the panel (no server-scoped metrics on a - // board route) and the dot reads the host-metrics source health, not the - // always-false server-scoped signal. + // board route). The HOST header carries no connection dot — the top-bar + // dot owns that signal (same current-server subscription health). expect(screen.getByText("board-host")).toBeInTheDocument(); - expect(screen.getByTitle("SSE connected")).toBeInTheDocument(); expect(screen.queryByText("No metrics")).not.toBeInTheDocument(); - }); - - it("HOST dot shows disconnected when host metrics are stale on the board route", () => { - renderSidebar({ - currentServer: null, - servers: boardServers, - sessionsByServer: boardSessionsMap, - focusedPane: null, - hostMetrics: HOST_METRICS, - hostMetricsConnected: false, - }); - expect(screen.getByTitle("SSE disconnected")).toBeInTheDocument(); + expect(screen.queryByTitle(/SSE (dis)?connected/)).not.toBeInTheDocument(); }); }); diff --git a/app/frontend/src/components/sidebar/index.tsx b/app/frontend/src/components/sidebar/index.tsx index fbb43a9c..07dc6fc2 100644 --- a/app/frontend/src/components/sidebar/index.tsx +++ b/app/frontend/src/components/sidebar/index.tsx @@ -1278,8 +1278,7 @@ export function Sidebar({ * Pulls from context so the data follows `currentServer`. On the board route * (`currentServer === null`) the route provides no window, so the PANE panel * follows the board's focused tile via the focused-pane context instead - * (260720-zx4i), and the HOST dot reflects host-metrics health rather than the - * always-false server-scoped signal. */ + * (260720-zx4i). */ function BottomPanels({ currentServer, currentSessionName, @@ -1292,12 +1291,6 @@ function BottomPanels({ const ctx = useSessionContext(); const focusedPane = useFocusedPane(); const sessions = currentServer ? ctx.sessionsByServer.get(currentServer) ?? [] : []; - // HOST dot source: the current server's subscription health when a server - // route is active; otherwise (board route) the host-metrics source health - // that feeds HostPanel's host-global fallback. - const isConnected = currentServer - ? ctx.isConnectedByServer.get(currentServer) ?? false - : ctx.hostMetricsConnected; const routeWindow = currentSessionName && currentWindowId != null ? sessions.find((s) => s.name === currentSessionName) ?.windows.find((w) => w.windowId === currentWindowId) ?? null @@ -1320,7 +1313,7 @@ function BottomPanels({ return ( <> - + ); } diff --git a/app/frontend/src/contexts/instance-accent-context.test.tsx b/app/frontend/src/contexts/instance-accent-context.test.tsx new file mode 100644 index 00000000..79f75274 --- /dev/null +++ b/app/frontend/src/contexts/instance-accent-context.test.tsx @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react"; +import { ThemeProvider } from "@/contexts/theme-context"; +import { ToastProvider } from "@/components/toast"; +import { InstanceAccentProvider, useInstanceAccent } from "./instance-accent-context"; +import { readInstanceColorEcho, writeInstanceColorEcho } from "@/instance-accent"; + +// Mock the API client module so no real HTTP calls happen in tests. +vi.mock("@/api/client", () => ({ + getThemePreference: vi.fn().mockRejectedValue(new Error("no API in test")), + setThemePreference: vi.fn().mockResolvedValue(undefined), + getInstanceColor: vi.fn(), + setInstanceColor: vi.fn().mockResolvedValue(undefined), +})); +import { getInstanceColor, setInstanceColor } from "@/api/client"; + +function mockMatchMedia() { + vi.stubGlobal( + "matchMedia", + vi.fn().mockReturnValue({ + matches: true, + media: "(prefers-color-scheme: dark)", + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + onchange: null, + dispatchEvent: vi.fn(), + }), + ); +} + +function Probe() { + const a = useInstanceAccent(); + return ( +
+ {String(a.color)} + {String(a.isExplicit)} + {String(a.stripeHex)} + {String(a.washHex)} + + +
+ ); +} + +function renderProvider() { + return render( + + + + + + + , + ); +} + +beforeEach(() => { + localStorage.clear(); + mockMatchMedia(); + document.head.innerHTML = ''; + vi.mocked(getInstanceColor).mockReset(); + vi.mocked(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 `