diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 115d03b3..fe325cd2 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -16,6 +16,7 @@ import { buildWorktreeConfig, buildWorktreeEnvEntries, formatShellExports, + generateWorktreeColor, resolveWorktreeSeedPlan, resolveWorktreeLocalPaths, rewriteLocalUrlPort, @@ -181,13 +182,22 @@ describe("worktree helpers", () => { path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"), ); - const env = buildWorktreeEnvEntries(paths); + const env = buildWorktreeEnvEntries(paths, { + name: "feature-worktree-support", + color: "#3abf7a", + }); expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees")); expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support"); expect(env.PAPERCLIP_IN_WORKTREE).toBe("true"); + expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support"); + expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a"); expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'"); }); + it("generates vivid worktree colors as hex", () => { + expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/); + }); + it("uses minimal seed mode to keep app state but drop heavy runtime history", () => { const minimal = resolveWorktreeSeedPlan("minimal"); const full = resolveWorktreeSeedPlan("full"); @@ -280,7 +290,10 @@ describe("worktree helpers", () => { }); const envPath = path.join(repoRoot, ".paperclip", ".env"); - expect(fs.readFileSync(envPath, "utf8")).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret"); + const envContents = fs.readFileSync(envPath, "utf8"); + expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret"); + expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo"); + expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=#[0-9a-f]{6}/); } finally { process.chdir(originalCwd); if (originalJwtSecret === undefined) { diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts index 4a0a3aeb..5249acc2 100644 --- a/cli/src/commands/worktree-lib.ts +++ b/cli/src/commands/worktree-lib.ts @@ -1,3 +1,4 @@ +import { randomInt } from "node:crypto"; import path from "node:path"; import type { PaperclipConfig } from "../config/schema.js"; import { expandHomePrefix } from "../config/home.js"; @@ -44,6 +45,11 @@ export type WorktreeLocalPaths = { storageDir: string; }; +export type WorktreeUiBranding = { + name: string; + color: string; +}; + export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode { return (WORKTREE_SEED_MODES as readonly string[]).includes(value); } @@ -87,6 +93,51 @@ export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string) return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd)); } +function hslComponentToHex(n: number): string { + return Math.round(Math.max(0, Math.min(255, n))) + .toString(16) + .padStart(2, "0"); +} + +function hslToHex(hue: number, saturation: number, lightness: number): string { + const s = Math.max(0, Math.min(100, saturation)) / 100; + const l = Math.max(0, Math.min(100, lightness)) / 100; + const c = (1 - Math.abs((2 * l) - 1)) * s; + const h = ((hue % 360) + 360) % 360; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - (c / 2); + + let r = 0; + let g = 0; + let b = 0; + + if (h < 60) { + r = c; + g = x; + } else if (h < 120) { + r = x; + g = c; + } else if (h < 180) { + g = c; + b = x; + } else if (h < 240) { + g = x; + b = c; + } else if (h < 300) { + r = x; + b = c; + } else { + r = c; + b = x; + } + + return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`; +} + +export function generateWorktreeColor(): string { + return hslToHex(randomInt(0, 360), 68, 56); +} + export function resolveWorktreeLocalPaths(opts: { cwd: string; homeDir?: string; @@ -196,13 +247,18 @@ export function buildWorktreeConfig(input: { }; } -export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record { +export function buildWorktreeEnvEntries( + paths: WorktreeLocalPaths, + branding?: WorktreeUiBranding, +): Record { return { PAPERCLIP_HOME: paths.homeDir, PAPERCLIP_INSTANCE_ID: paths.instanceId, PAPERCLIP_CONFIG: paths.configPath, PAPERCLIP_CONTEXT: paths.contextPath, PAPERCLIP_IN_WORKTREE: "true", + ...(branding?.name ? { PAPERCLIP_WORKTREE_NAME: branding.name } : {}), + ...(branding?.color ? { PAPERCLIP_WORKTREE_COLOR: branding.color } : {}), }; } diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 582bb5dd..fca320b9 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -39,6 +39,7 @@ import { buildWorktreeEnvEntries, DEFAULT_WORKTREE_HOME, formatShellExports, + generateWorktreeColor, isWorktreeSeedMode, resolveSuggestedWorktreeName, resolveWorktreeSeedPlan, @@ -623,7 +624,7 @@ async function seedWorktreeDatabase(input: { async function runWorktreeInit(opts: WorktreeInitOptions): Promise { const cwd = process.cwd(); - const name = resolveSuggestedWorktreeName( + const worktreeName = resolveSuggestedWorktreeName( cwd, opts.name ?? detectGitBranchName(cwd) ?? undefined, ); @@ -631,12 +632,16 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { if (!isWorktreeSeedMode(seedMode)) { throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`); } - const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name); + const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? worktreeName); const paths = resolveWorktreeLocalPaths({ cwd, homeDir: resolveWorktreeHome(opts.home), instanceId, }); + const branding = { + name: worktreeName, + color: generateWorktreeColor(), + }; const sourceConfigPath = resolveSourceConfigPath(opts); const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null; @@ -669,7 +674,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET); mergePaperclipEnvEntries( { - ...buildWorktreeEnvEntries(paths), + ...buildWorktreeEnvEntries(paths, branding), ...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}), }, paths.envPath, @@ -710,6 +715,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { p.log.message(pc.dim(`Repo env: ${paths.envPath}`)); p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`)); p.log.message(pc.dim(`Instance: ${paths.instanceId}`)); + p.log.message(pc.dim(`Worktree badge: ${branding.name} (${branding.color})`)); p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`)); if (copiedGitHooks?.copied) { p.log.message( diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 1ca1409b..4b379dcb 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -152,7 +152,13 @@ Seed modes: After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance. -That repo-local env also sets `PAPERCLIP_IN_WORKTREE=true`, which the server can use for worktree-specific UI behavior such as an alternate favicon. +That repo-local env also sets: + +- `PAPERCLIP_IN_WORKTREE=true` +- `PAPERCLIP_WORKTREE_NAME=` +- `PAPERCLIP_WORKTREE_COLOR=` + +The server/UI use those values for worktree-specific branding such as the top banner and dynamically colored favicon. Print shell exports explicitly when needed: diff --git a/server/src/__tests__/ui-branding.test.ts b/server/src/__tests__/ui-branding.test.ts index 858823bb..c649d5c9 100644 --- a/server/src/__tests__/ui-branding.test.ts +++ b/server/src/__tests__/ui-branding.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it } from "vitest"; -import { applyUiBranding, isWorktreeUiBrandingEnabled, renderFaviconLinks } from "../ui-branding.js"; +import { + applyUiBranding, + getWorktreeUiBranding, + isWorktreeUiBrandingEnabled, + renderFaviconLinks, + renderRuntimeBrandingMeta, +} from "../ui-branding.js"; const TEMPLATE = ` + + @@ -18,21 +26,57 @@ describe("ui branding", () => { expect(isWorktreeUiBrandingEnabled({ PAPERCLIP_IN_WORKTREE: "false" })).toBe(false); }); - it("renders the worktree favicon asset set when enabled", () => { - const links = renderFaviconLinks(true); - expect(links).toContain("/worktree-favicon.ico"); - expect(links).toContain("/worktree-favicon.svg"); - expect(links).toContain("/worktree-favicon-32x32.png"); - expect(links).toContain("/worktree-favicon-16x16.png"); + it("resolves name, color, and text color for worktree branding", () => { + const branding = getWorktreeUiBranding({ + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432", + PAPERCLIP_WORKTREE_COLOR: "#4f86f7", + }); + + expect(branding.enabled).toBe(true); + expect(branding.name).toBe("paperclip-pr-432"); + expect(branding.color).toBe("#4f86f7"); + expect(branding.textColor).toMatch(/^#[0-9a-f]{6}$/); + expect(branding.faviconHref).toContain("data:image/svg+xml,"); }); - it("rewrites the favicon block for worktree instances only", () => { - const branded = applyUiBranding(TEMPLATE, { PAPERCLIP_IN_WORKTREE: "true" }); - expect(branded).toContain("/worktree-favicon.svg"); + it("renders a dynamic worktree favicon when enabled", () => { + const links = renderFaviconLinks( + getWorktreeUiBranding({ + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432", + PAPERCLIP_WORKTREE_COLOR: "#4f86f7", + }), + ); + expect(links).toContain("data:image/svg+xml,"); + expect(links).toContain('rel="shortcut icon"'); + }); + + it("renders runtime branding metadata for the ui", () => { + const meta = renderRuntimeBrandingMeta( + getWorktreeUiBranding({ + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432", + PAPERCLIP_WORKTREE_COLOR: "#4f86f7", + }), + ); + expect(meta).toContain('name="paperclip-worktree-name"'); + expect(meta).toContain('content="paperclip-pr-432"'); + expect(meta).toContain('name="paperclip-worktree-color"'); + }); + + it("rewrites the favicon and runtime branding blocks for worktree instances only", () => { + const branded = applyUiBranding(TEMPLATE, { + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_WORKTREE_NAME: "paperclip-pr-432", + PAPERCLIP_WORKTREE_COLOR: "#4f86f7", + }); + expect(branded).toContain("data:image/svg+xml,"); + expect(branded).toContain('name="paperclip-worktree-name"'); expect(branded).not.toContain('href="/favicon.svg"'); const defaultHtml = applyUiBranding(TEMPLATE, {}); expect(defaultHtml).toContain('href="/favicon.svg"'); - expect(defaultHtml).not.toContain("/worktree-favicon.svg"); + expect(defaultHtml).not.toContain('name="paperclip-worktree-name"'); }); }); diff --git a/server/src/ui-branding.ts b/server/src/ui-branding.ts index bb6f3a33..8195c91e 100644 --- a/server/src/ui-branding.ts +++ b/server/src/ui-branding.ts @@ -1,5 +1,7 @@ const FAVICON_BLOCK_START = ""; const FAVICON_BLOCK_END = ""; +const RUNTIME_BRANDING_BLOCK_START = ""; +const RUNTIME_BRANDING_BLOCK_END = ""; const DEFAULT_FAVICON_LINKS = [ '', @@ -8,12 +10,13 @@ const DEFAULT_FAVICON_LINKS = [ '', ].join("\n"); -const WORKTREE_FAVICON_LINKS = [ - '', - '', - '', - '', -].join("\n"); +export type WorktreeUiBranding = { + enabled: boolean; + name: string | null; + color: string | null; + textColor: string | null; + faviconHref: string | null; +}; function isTruthyEnvValue(value: string | undefined): boolean { if (!value) return false; @@ -21,21 +24,194 @@ function isTruthyEnvValue(value: string | undefined): boolean { return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; } +function nonEmpty(value: string | undefined): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} + +function normalizeHexColor(value: string | undefined): string | null { + const raw = nonEmpty(value); + if (!raw) return null; + const hex = raw.startsWith("#") ? raw.slice(1) : raw; + if (/^[0-9a-fA-F]{3}$/.test(hex)) { + return `#${hex.split("").map((char) => `${char}${char}`).join("").toLowerCase()}`; + } + if (/^[0-9a-fA-F]{6}$/.test(hex)) { + return `#${hex.toLowerCase()}`; + } + return null; +} + +function hslComponentToHex(n: number): string { + return Math.round(Math.max(0, Math.min(255, n))) + .toString(16) + .padStart(2, "0"); +} + +function hslToHex(hue: number, saturation: number, lightness: number): string { + const s = Math.max(0, Math.min(100, saturation)) / 100; + const l = Math.max(0, Math.min(100, lightness)) / 100; + const c = (1 - Math.abs((2 * l) - 1)) * s; + const h = ((hue % 360) + 360) % 360; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - (c / 2); + + let r = 0; + let g = 0; + let b = 0; + + if (h < 60) { + r = c; + g = x; + } else if (h < 120) { + r = x; + g = c; + } else if (h < 180) { + g = c; + b = x; + } else if (h < 240) { + g = x; + b = c; + } else if (h < 300) { + r = x; + b = c; + } else { + r = c; + b = x; + } + + return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`; +} + +function deriveColorFromSeed(seed: string): string { + let hash = 0; + for (const char of seed) { + hash = ((hash * 33) + char.charCodeAt(0)) >>> 0; + } + return hslToHex(hash % 360, 68, 56); +} + +function hexToRgb(color: string): { r: number; g: number; b: number } { + const normalized = normalizeHexColor(color) ?? "#000000"; + return { + r: Number.parseInt(normalized.slice(1, 3), 16), + g: Number.parseInt(normalized.slice(3, 5), 16), + b: Number.parseInt(normalized.slice(5, 7), 16), + }; +} + +function relativeLuminanceChannel(value: number): number { + const normalized = value / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; +} + +function relativeLuminance(color: string): number { + const { r, g, b } = hexToRgb(color); + return ( + (0.2126 * relativeLuminanceChannel(r)) + + (0.7152 * relativeLuminanceChannel(g)) + + (0.0722 * relativeLuminanceChannel(b)) + ); +} + +function pickReadableTextColor(background: string): string { + const backgroundLuminance = relativeLuminance(background); + const whiteContrast = 1.05 / (backgroundLuminance + 0.05); + const blackContrast = (backgroundLuminance + 0.05) / 0.05; + return whiteContrast >= blackContrast ? "#f8fafc" : "#111827"; +} + +function escapeHtmlAttribute(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function createFaviconDataUrl(background: string, foreground: string): string { + const svg = [ + '', + ``, + ``, + "", + ].join(""); + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + export function isWorktreeUiBrandingEnabled(env: NodeJS.ProcessEnv = process.env): boolean { return isTruthyEnvValue(env.PAPERCLIP_IN_WORKTREE); } -export function renderFaviconLinks(worktree: boolean): string { - return worktree ? WORKTREE_FAVICON_LINKS : DEFAULT_FAVICON_LINKS; +export function getWorktreeUiBranding(env: NodeJS.ProcessEnv = process.env): WorktreeUiBranding { + if (!isWorktreeUiBrandingEnabled(env)) { + return { + enabled: false, + name: null, + color: null, + textColor: null, + faviconHref: null, + }; + } + + const name = nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? "worktree"; + const color = normalizeHexColor(env.PAPERCLIP_WORKTREE_COLOR) ?? deriveColorFromSeed(name); + const textColor = pickReadableTextColor(color); + + return { + enabled: true, + name, + color, + textColor, + faviconHref: createFaviconDataUrl(color, textColor), + }; +} + +export function renderFaviconLinks(branding: WorktreeUiBranding): string { + if (!branding.enabled || !branding.faviconHref) return DEFAULT_FAVICON_LINKS; + + const href = escapeHtmlAttribute(branding.faviconHref); + return [ + ``, + ``, + ].join("\n"); +} + +export function renderRuntimeBrandingMeta(branding: WorktreeUiBranding): string { + if (!branding.enabled || !branding.name || !branding.color || !branding.textColor) return ""; + + return [ + '', + ``, + ``, + ``, + ].join("\n"); +} + +function replaceMarkedBlock(html: string, startMarker: string, endMarker: string, content: string): string { + const start = html.indexOf(startMarker); + const end = html.indexOf(endMarker); + if (start === -1 || end === -1 || end < start) return html; + + const before = html.slice(0, start + startMarker.length); + const after = html.slice(end); + const indentedContent = content + ? `\n${content + .split("\n") + .map((line) => ` ${line}`) + .join("\n")}\n ` + : "\n "; + return `${before}${indentedContent}${after}`; } export function applyUiBranding(html: string, env: NodeJS.ProcessEnv = process.env): string { - const start = html.indexOf(FAVICON_BLOCK_START); - const end = html.indexOf(FAVICON_BLOCK_END); - if (start === -1 || end === -1 || end < start) return html; - - const before = html.slice(0, start + FAVICON_BLOCK_START.length); - const after = html.slice(end); - const links = renderFaviconLinks(isWorktreeUiBrandingEnabled(env)); - return `${before}\n${links}\n ${after}`; + const branding = getWorktreeUiBranding(env); + const withFavicon = replaceMarkedBlock(html, FAVICON_BLOCK_START, FAVICON_BLOCK_END, renderFaviconLinks(branding)); + return replaceMarkedBlock( + withFavicon, + RUNTIME_BRANDING_BLOCK_START, + RUNTIME_BRANDING_BLOCK_END, + renderRuntimeBrandingMeta(branding), + ); } diff --git a/ui/index.html b/ui/index.html index 7994c0d2..1bb9152e 100644 --- a/ui/index.html +++ b/ui/index.html @@ -8,6 +8,8 @@ Paperclip + + diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index e484b265..43094b51 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -14,6 +14,7 @@ import { NewGoalDialog } from "./NewGoalDialog"; import { NewAgentDialog } from "./NewAgentDialog"; import { ToastViewport } from "./ToastViewport"; import { MobileBottomNav } from "./MobileBottomNav"; +import { WorktreeBanner } from "./WorktreeBanner"; import { useDialog } from "../context/DialogContext"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; @@ -223,7 +224,7 @@ export function Layout() {
Skip to Main Content - {/* Mobile backdrop */} - {isMobile && sidebarOpen && ( - - -
- - - ) : ( -
-
- -
- {isInstanceSettingsRoute ? : } -
-
-
-
- - - Documentation - - - -
-
-
- )} - - {/* Main content */} -
-
- -
-
-
- {hasUnknownCompanyPrefix ? ( - - ) : ( - +
+ + {isInstanceSettingsRoute ? : } +
+
+
+ + + Documentation + + + +
+
+
+ ) : ( +
+
+ +
+ {isInstanceSettingsRoute ? : } +
+
+
+
+ + + Documentation + + + +
+
+
+ )} + + {/* Main content */} +
+
- + > + +
+
+
+ {hasUnknownCompanyPrefix ? ( + + ) : ( + + )} +
+ +
{isMobile && } diff --git a/ui/src/components/WorktreeBanner.tsx b/ui/src/components/WorktreeBanner.tsx new file mode 100644 index 00000000..6808b2da --- /dev/null +++ b/ui/src/components/WorktreeBanner.tsx @@ -0,0 +1,25 @@ +import { getWorktreeUiBranding } from "../lib/worktree-branding"; + +export function WorktreeBanner() { + const branding = getWorktreeUiBranding(); + if (!branding) return null; + + return ( +
+
+ Worktree +
+
+ ); +} diff --git a/ui/src/lib/worktree-branding.ts b/ui/src/lib/worktree-branding.ts new file mode 100644 index 00000000..6f6d8dc4 --- /dev/null +++ b/ui/src/lib/worktree-branding.ts @@ -0,0 +1,65 @@ +export type WorktreeUiBranding = { + enabled: true; + name: string; + color: string; + textColor: string; +}; + +function readMetaContent(name: string): string | null { + if (typeof document === "undefined") return null; + const element = document.querySelector(`meta[name="${name}"]`); + const content = element?.getAttribute("content")?.trim(); + return content ? content : null; +} + +function normalizeHexColor(value: string | null): string | null { + if (!value) return null; + const hex = value.startsWith("#") ? value.slice(1) : value; + if (/^[0-9a-fA-F]{3}$/.test(hex)) { + return `#${hex.split("").map((char) => `${char}${char}`).join("").toLowerCase()}`; + } + if (/^[0-9a-fA-F]{6}$/.test(hex)) { + return `#${hex.toLowerCase()}`; + } + return null; +} + +function hexToRgb(color: string): { r: number; g: number; b: number } { + const normalized = normalizeHexColor(color) ?? "#000000"; + return { + r: Number.parseInt(normalized.slice(1, 3), 16), + g: Number.parseInt(normalized.slice(3, 5), 16), + b: Number.parseInt(normalized.slice(5, 7), 16), + }; +} + +function relativeLuminanceChannel(value: number): number { + const normalized = value / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; +} + +function pickReadableTextColor(background: string): string { + const { r, g, b } = hexToRgb(background); + const luminance = + (0.2126 * relativeLuminanceChannel(r)) + + (0.7152 * relativeLuminanceChannel(g)) + + (0.0722 * relativeLuminanceChannel(b)); + const whiteContrast = 1.05 / (luminance + 0.05); + const blackContrast = (luminance + 0.05) / 0.05; + return whiteContrast >= blackContrast ? "#f8fafc" : "#111827"; +} + +export function getWorktreeUiBranding(): WorktreeUiBranding | null { + if (readMetaContent("paperclip-worktree-enabled") !== "true") return null; + + const name = readMetaContent("paperclip-worktree-name"); + const color = normalizeHexColor(readMetaContent("paperclip-worktree-color")); + if (!name || !color) return null; + + return { + enabled: true, + name, + color, + textColor: normalizeHexColor(readMetaContent("paperclip-worktree-text-color")) ?? pickReadableTextColor(color), + }; +}