const FAVICON_BLOCK_START = "";
const FAVICON_BLOCK_END = "";
const RUNTIME_BRANDING_BLOCK_START = "";
const RUNTIME_BRANDING_BLOCK_END = "";
const DEFAULT_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;
const normalized = value.trim().toLowerCase();
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 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 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),
);
}