Merge pull request #805 from paperclipai/fix/worktree-ui-branding
Add worktree UI branding
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<string, string> {
|
||||
export function buildWorktreeEnvEntries(
|
||||
paths: WorktreeLocalPaths,
|
||||
branding?: WorktreeUiBranding,
|
||||
): Record<string, string> {
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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(
|
||||
|
||||
@@ -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=<worktree-name>`
|
||||
- `PAPERCLIP_WORKTREE_COLOR=<hex-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:
|
||||
|
||||
|
||||
@@ -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 = `<!doctype html>
|
||||
<head>
|
||||
<!-- PAPERCLIP_RUNTIME_BRANDING_START -->
|
||||
<!-- PAPERCLIP_RUNTIME_BRANDING_END -->
|
||||
<!-- PAPERCLIP_FAVICON_START -->
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
it("rewrites the favicon block for worktree instances only", () => {
|
||||
const branded = applyUiBranding(TEMPLATE, { PAPERCLIP_IN_WORKTREE: "true" });
|
||||
expect(branded).toContain("/worktree-favicon.svg");
|
||||
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("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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const FAVICON_BLOCK_START = "<!-- PAPERCLIP_FAVICON_START -->";
|
||||
const FAVICON_BLOCK_END = "<!-- PAPERCLIP_FAVICON_END -->";
|
||||
const RUNTIME_BRANDING_BLOCK_START = "<!-- PAPERCLIP_RUNTIME_BRANDING_START -->";
|
||||
const RUNTIME_BRANDING_BLOCK_END = "<!-- PAPERCLIP_RUNTIME_BRANDING_END -->";
|
||||
|
||||
const DEFAULT_FAVICON_LINKS = [
|
||||
'<link rel="icon" href="/favicon.ico" sizes="48x48" />',
|
||||
@@ -8,12 +10,13 @@ const DEFAULT_FAVICON_LINKS = [
|
||||
'<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />',
|
||||
].join("\n");
|
||||
|
||||
const WORKTREE_FAVICON_LINKS = [
|
||||
'<link rel="icon" href="/worktree-favicon.ico" sizes="48x48" />',
|
||||
'<link rel="icon" href="/worktree-favicon.svg" type="image/svg+xml" />',
|
||||
'<link rel="icon" type="image/png" sizes="32x32" href="/worktree-favicon-32x32.png" />',
|
||||
'<link rel="icon" type="image/png" sizes="16x16" href="/worktree-favicon-16x16.png" />',
|
||||
].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 = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">',
|
||||
`<rect width="24" height="24" rx="6" fill="${background}"/>`,
|
||||
`<path stroke="${foreground}" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.15" d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>`,
|
||||
"</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 [
|
||||
`<link rel="icon" href="${href}" type="image/svg+xml" sizes="any" />`,
|
||||
`<link rel="shortcut icon" href="${href}" type="image/svg+xml" />`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function renderRuntimeBrandingMeta(branding: WorktreeUiBranding): string {
|
||||
if (!branding.enabled || !branding.name || !branding.color || !branding.textColor) return "";
|
||||
|
||||
return [
|
||||
'<meta name="paperclip-worktree-enabled" content="true" />',
|
||||
`<meta name="paperclip-worktree-name" content="${escapeHtmlAttribute(branding.name)}" />`,
|
||||
`<meta name="paperclip-worktree-color" content="${escapeHtmlAttribute(branding.color)}" />`,
|
||||
`<meta name="paperclip-worktree-text-color" content="${escapeHtmlAttribute(branding.textColor)}" />`,
|
||||
].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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Paperclip" />
|
||||
<title>Paperclip</title>
|
||||
<!-- PAPERCLIP_RUNTIME_BRANDING_START -->
|
||||
<!-- PAPERCLIP_RUNTIME_BRANDING_END -->
|
||||
<!-- PAPERCLIP_FAVICON_START -->
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
|
||||
@@ -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() {
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
|
||||
isMobile ? "min-h-dvh" : "flex h-dvh overflow-hidden",
|
||||
isMobile ? "min-h-dvh" : "flex h-dvh flex-col overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<a
|
||||
@@ -232,6 +233,8 @@ export function Layout() {
|
||||
>
|
||||
Skip to Main Content
|
||||
</a>
|
||||
<WorktreeBanner />
|
||||
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
|
||||
{/* Mobile backdrop */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<button
|
||||
@@ -292,7 +295,7 @@ export function Layout() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col shrink-0 h-full">
|
||||
<div className="flex h-full flex-col shrink-0">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<CompanyRail />
|
||||
<div
|
||||
@@ -373,6 +376,7 @@ export function Layout() {
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}
|
||||
<CommandPalette />
|
||||
<NewIssueDialog />
|
||||
|
||||
25
ui/src/components/WorktreeBanner.tsx
Normal file
25
ui/src/components/WorktreeBanner.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getWorktreeUiBranding } from "../lib/worktree-branding";
|
||||
|
||||
export function WorktreeBanner() {
|
||||
const branding = getWorktreeUiBranding();
|
||||
if (!branding) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden border-b px-3 py-1.5 text-[11px] font-medium tracking-[0.2em] uppercase"
|
||||
style={{
|
||||
backgroundColor: branding.color,
|
||||
color: branding.textColor,
|
||||
borderColor: `${branding.textColor}22`,
|
||||
boxShadow: `inset 0 -1px 0 ${branding.textColor}18`,
|
||||
backgroundImage: `linear-gradient(90deg, ${branding.textColor}14, transparent 28%, transparent 72%, ${branding.textColor}12), repeating-linear-gradient(135deg, transparent 0 10px, ${branding.textColor}08 10px 20px)`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
<span className="shrink-0 opacity-70">Worktree</span>
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-70" aria-hidden="true" />
|
||||
<span className="truncate font-semibold tracking-[0.12em]">{branding.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
ui/src/lib/worktree-branding.ts
Normal file
65
ui/src/lib/worktree-branding.ts
Normal file
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user