← back to Kickbacks Ai Upstream

scripts/_brand.mjs

148 lines

// Shared Kickbacks brand renderer. The mark is the "K$" wordmark in Montserrat
// 800 (white) on a vertical green gradient rounded square — a real typeface, so
// it's rasterized with headless Chromium (Playwright) rather than drawn
// procedurally. Montserrat is pulled from Google Fonts at render time.
//
// Used by gen-icon.mjs (marketplace icon) and gen-logos.mjs (full asset set).
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { join } from "node:path";

// --- brand constants ---
export const GRAD_TOP = "#2aa44f";
export const GRAD_BOT = "#147a34";
export const GREEN = "#188a45";      // solid brand green (mono mark)
export const INK = "#17181a";
export const RADIUS_RATIO = 0.22;    // rounded-square corner radius
export const FONT_RATIO = 0.47;      // K$ cap size vs icon size
export const FONT_STACK =
  "Montserrat, -apple-system, 'Segoe UI', Arial, sans-serif";

// Resolve playwright-core from one of the repo's e2e installs (no dedicated dep
// in extension/). Portable: paths are relative to the repo root.
export function loadChromium() {
  const root = fileURLToPath(new URL("../../", import.meta.url));
  const candidates = [
    "site/e2e",
    "test-stack/site-e2e",
    "test-stack/stripe-e2e",
    "test-stack",
  ].map((p) => join(root, p, "package.json"));
  for (const c of candidates) {
    try {
      return createRequire(c)("playwright-core").chromium;
    } catch {}
  }
  try {
    return createRequire(import.meta.url)("playwright-core").chromium;
  } catch {}
  throw new Error(
    "playwright-core not found. Install it (npm i -D playwright-core && npx playwright install chromium) " +
    "or run from a checkout that has site/e2e installed."
  );
}

const FONT_LINKS = `
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700;800&display=swap" rel="stylesheet">`;

function iconHTML(size) {
  const r = Math.round(size * RADIUS_RATIO);
  const fs = Math.round(size * FONT_RATIO);
  return `<!doctype html><html><head><meta charset="utf-8">${FONT_LINKS}
<style>html,body{margin:0;background:transparent}
#mark{width:${size}px;height:${size}px;border-radius:${r}px;
background:linear-gradient(180deg,${GRAD_TOP} 0%,${GRAD_BOT} 100%);
display:flex;align-items:center;justify-content:center;color:#fff;
font-family:${FONT_STACK};font-weight:800;font-size:${fs}px;line-height:1;
letter-spacing:-.02em}</style></head>
<body><div id="mark">K$</div></body></html>`;
}

function lockupHTML(height) {
  const badge = Math.round(height * 0.92);
  const r = Math.round(badge * RADIUS_RATIO);
  const bfs = Math.round(badge * FONT_RATIO);
  const wfs = Math.round(height * 0.62);
  return `<!doctype html><html><head><meta charset="utf-8">${FONT_LINKS}
<style>html,body{margin:0;background:transparent}
#lk{display:inline-flex;align-items:center;gap:${Math.round(height * 0.22)}px;padding:${Math.round(height * 0.12)}px}
#b{width:${badge}px;height:${badge}px;border-radius:${r}px;flex:none;
background:linear-gradient(180deg,${GRAD_TOP} 0%,${GRAD_BOT} 100%);
display:flex;align-items:center;justify-content:center;color:#fff;
font-family:${FONT_STACK};font-weight:800;font-size:${bfs}px;line-height:1;letter-spacing:-.02em}
#w{font-family:${FONT_STACK};font-weight:700;font-size:${wfs}px;color:${INK};
letter-spacing:-.02em;line-height:1}</style></head>
<body><div id="lk"><div id="b">K$</div><div id="w">Kickbacks.ai</div></div></body></html>`;
}

// Render a batch of icon sizes + an optional lockup with ONE browser launch.
// Returns { icons: Map<size, Buffer>, lockup?: Buffer }.
export async function renderAssets({ sizes = [], lockupHeight = 0 } = {}) {
  const chromium = loadChromium();
  const browser = await chromium.launch({ headless: true });
  const icons = new Map();
  let lockup;
  try {
    for (const size of sizes) {
      const page = await browser.newPage({
        viewport: { width: size + 40, height: size + 40 },
        deviceScaleFactor: 1,
      });
      await page.setContent(iconHTML(size), { waitUntil: "networkidle" });
      await page.evaluate(() => document.fonts.ready);
      const buf = await page.locator("#mark").screenshot({ omitBackground: true });
      icons.set(size, buf);
      await page.close();
    }
    if (lockupHeight) {
      const page = await browser.newPage({
        viewport: { width: 1200, height: lockupHeight * 3 },
        deviceScaleFactor: 2,
      });
      await page.setContent(lockupHTML(lockupHeight), { waitUntil: "networkidle" });
      await page.evaluate(() => document.fonts.ready);
      lockup = await page.locator("#lk").screenshot({ omitBackground: true });
      await page.close();
    }
  } finally {
    await browser.close();
  }
  return { icons, lockup };
}

// --- SVG variants (font-based; render true Montserrat where the font is
// available, fall back to a bold sans elsewhere) ---
export function markSVG({ box = true, fill = "#fff" } = {}) {
  const rect = box
    ? `\n  <defs><linearGradient id="kb" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${GRAD_TOP}"/><stop offset="1" stop-color="${GRAD_BOT}"/></linearGradient></defs>\n  <rect x="4" y="4" width="120" height="120" rx="28" fill="url(#kb)"/>`
    : "";
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Kickbacks.ai">${rect}
  <text x="64" y="64" text-anchor="middle" dominant-baseline="central" font-family="${FONT_STACK}" font-weight="800" font-size="62" letter-spacing="-2" fill="${fill}">K$</text>
</svg>`;
}

// --- ICO encoder (packs PNG buffers) ---
export function toICO(entries) {
  const count = entries.length;
  const header = Buffer.alloc(6);
  header.writeUInt16LE(0, 0);
  header.writeUInt16LE(1, 2);
  header.writeUInt16LE(count, 4);
  let offset = 6 + count * 16;
  const dir = [];
  for (const { size, png } of entries) {
    const e = Buffer.alloc(16);
    e[0] = size >= 256 ? 0 : size;
    e[1] = size >= 256 ? 0 : size;
    e.writeUInt16LE(1, 4);
    e.writeUInt16LE(32, 6);
    e.writeUInt32LE(png.length, 8);
    e.writeUInt32LE(offset, 12);
    dir.push(e);
    offset += png.length;
  }
  return Buffer.concat([header, ...dir, ...entries.map((e) => e.png)]);
}