Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/skills/create-launch-post/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ pnpm render-tile-images -- \
```

4. When Rivet itself is one of the tiles, pass `src/images/rivet-logos/icon-white.svg` and add `--ink-tile <n>` (1-based, left to right) for that position. That tile is painted as the product-mark badge — ink fill, `34.375%` radius, white ring and R filling the tile — instead of a black badge floating inside a white app tile. Never place a Rivet or product wordmark in a light tile. The badge is sized level with the smallest neighboring tile. Add `--no-wordmark` to drop the Rivet wordmark above the title when the title or a tile already carries the Rivet mark; the title and tiles shift up to stay balanced.
5. For a diagram hero instead of tiles, start from `scripts/render-byoc-hero.ts` (`pnpm render-byoc-hero -- --output-dir <path>`): a colored, simplified architecture diagram on paper using pine, sage, ink, one accent arrow, and brand-colored provider marks. Copy and adapt it per launch rather than adding flags.
5. Inspect both PNGs. If a title wraps to a third line or a tile crowds the title, shorten the title rather than hand-editing the output. Tile geometry lives in `TILE_LAYOUTS` in the renderer; change it there if a lockup genuinely needs different placement.
5. For the "Introducing" lockup used by the Rivet MCP and Rivet BYOC heroes (eyebrow, ink Rivet badge beside the title, a row of white tiles with brand-colored marks), start from `scripts/render-byoc-hero.ts` (`pnpm render-byoc-hero -- --output-dir <path> [--vpc] [--frames <dir>]`). `--vpc` adds the dashed pine "Your VPC" outline around the tiles; `--frames` writes 1920x1080 animation frames for the GIF/MP4 instead of the stills. Copy and adapt it per launch rather than adding flags. Encode the frames with ffmpeg: MP4 `-framerate 30 -i f%04d.png -c:v libx264 -pix_fmt yuv420p -crf 18 -movflags +faststart`; GIF `-vf "fps=20,scale=1200:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=128:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" -loop 0`.
6. Inspect both PNGs. If a title wraps to a third line or a tile crowds the title, shorten the title rather than hand-editing the output. Tile geometry lives in `TILE_LAYOUTS` in the renderer; change it there if a lockup genuinely needs different placement.

When short code demonstrations would help launch distribution, create a temporary JSON file outside the repository with one to four sections:

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
213 changes: 146 additions & 67 deletions .claude/skills/create-launch-post/scripts/render-byoc-hero.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,30 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";

// Rivet BYOC hero: a colored, simplified hero-scale take on the docs'
// ByocArchitectureDiagram. Writes image.png (2048x1024) and social.png
// (2048x1238) plus scene.html.
// Rivet BYOC hero in the "Introducing Rivet MCP" layout: eyebrow, ink Rivet
// badge beside the title, and a row of white tiles carrying brand-colored
// provider marks. Writes image.png (2048x1024), social.png (2048x1238) and
// scene.html.
//
// pnpm render-byoc-hero -- --output-dir /tmp/byoc-hero
// pnpm render-byoc-hero -- --output-dir /tmp/byoc-hero [--vpc]
//
// --vpc draws a dashed pine "Your VPC" outline around the tile row.
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.resolve(SCRIPT_DIR, "../../../..");
const ASSETS = path.resolve(SCRIPT_DIR, "../assets/logos");
const CARD_W = 2048;
const CARD_H = 1024;

const INK = "#1B1916";
const INK_SOFT = "#56524A";
const PAPER = "#EFEFEF";
const CREAM = "#F4F1E7";
const PINE = "#2E4034";
const SAGE = "#93A286";
const ACCENT = "#CB5A33";
const AWS_ORANGE = "#FF9900";
const GCP_BLUE = "#4285F4";
const K8S_BLUE = "#326CE5";
const TERRAFORM_PURPLE = "#7B42BC";

const strip = (s: string) => s.replace(/<\?xml[^>]*\?>/i, "").replace(/<!DOCTYPE[^>]*>/i, "");
const strip = (s: string) => s.replace(/<\?xml[^>]*\?>/i, "").replace(/<!DOCTYPE[^>]*>/i, "").replace(/<title>[^<]*<\/title>/i, "");
const ensureViewBox = (svg: string) => {
const open = svg.match(/<svg\b[^>]*>/i)?.[0];
if (!open || /\bviewBox=/i.test(open)) return svg;
Expand All @@ -32,99 +35,175 @@ const ensureViewBox = (svg: string) => {
return w && h ? svg.replace(open, open.replace(/<svg\b/i, `<svg viewBox="0 0 ${w} ${h}"`)) : svg;
};
const pathData = (svg: string) => svg.match(/\sd="([^"]+)"/)![1];
const recolor = (svg: string, fill: string) =>
/\bfill="/i.test(svg.match(/<svg\b[^>]*>/i)![0]) || /<path\b[^>]*fill=/i.test(svg)
? svg.replace(/fill="#[0-9a-f]{3,6}"/gi, `fill="${fill}"`)
: svg.replace(/<svg\b/i, `<svg fill="${fill}"`);

// The registry AWS mark is one path: four subpaths for "aws" then two for the
// smile. Split them so the smile can take the brand orange.
function awsMark(svg: string): string {
const subs = pathData(svg).split("z").filter((s) => s.trim()).map((s) => s + "z");
const letters = subs.slice(0, 4).join("");
// Absolute starts of the two smile subpaths, resolved from the relative moves.
const smile =
"M578.59 368.94" + subs[4].replace(/^m[\d.\- ]+/, "") +
"M607.78 335.65" + subs[5].replace(/^m[\d.\- ]+/, "");
return `<svg viewBox="0 0 640 512" xmlns="http://www.w3.org/2000/svg"><path d="${letters}" fill="${INK}"/><path d="${smile}" fill="${AWS_ORANGE}"/></svg>`;
}

async function buildHtml(): Promise<string> {
const [font, badgeRaw, awsRaw, gcpRaw] = await Promise.all([
async function buildHtml(vpc: boolean): Promise<string> {
const [font, badgeRaw, awsRaw, gcpRaw, k8sRaw, tfRaw] = await Promise.all([
readFile(path.join(REPO, "public/fonts/manrope/Manrope-Variable-latin.woff2")),
readFile(path.join(REPO, "src/images/rivet-logos/icon-white.svg"), "utf8"),
readFile(path.join(REPO, "public/images/registry/deploy-aws-ecs.svg"), "utf8"),
readFile(path.join(REPO, "public/images/registry/deploy-gcp-cloud-run.svg"), "utf8"),
readFile(path.join(REPO, "public/images/registry/deploy-kubernetes.svg"), "utf8"),
readFile(path.join(ASSETS, "terraform.svg"), "utf8"),
]);
const badge = ensureViewBox(strip(badgeRaw)).replace(/#f0f0f0\b/gi, "#FFFFFF").replace(/#0f0f0f\b/gi, INK);
const aws = awsMark(strip(awsRaw));
const gcp = strip(gcpRaw).replace(/fill="#1b1916"/i, `fill="${GCP_BLUE}"`);
const tiles: { svg: string; scale: number }[] = [
{ svg: awsMark(strip(awsRaw)), scale: 0.62 },
{ svg: recolor(strip(gcpRaw), GCP_BLUE), scale: 0.56 },
{ svg: recolor(strip(k8sRaw), K8S_BLUE), scale: 0.74 },
{ svg: recolor(strip(tfRaw), TERRAFORM_PURPLE), scale: 0.44 },
];

const TILE = 140;
const GAP = 28;
const rowW = tiles.length * TILE + (tiles.length - 1) * GAP;
const rowX = Math.round((CARD_W - rowW) / 2);
const rowY = vpc ? 656 : 598;
const tileHtml = tiles
.map((t, i) => {
const inner = Math.round(TILE * t.scale);
return `<div class="tile anim-tile" id="tile${i}" style="left:${rowX + i * (TILE + GAP)}px;top:${rowY}px;width:${TILE}px;height:${TILE}px">
<div class="mark" style="width:${inner}px;height:${inner}px">${t.svg}</div></div>`;
})
.join("");

const PAD = 44;
const vpcHtml = vpc
? (() => {
// The dashed outline is masked by a solid stroke on the same geometry;
// the animation shortens that stroke's dash so the line draws on
// clockwise from the top-left corner.
const geom = `x="${rowX - PAD}" y="${rowY - PAD - 26}" width="${rowW + PAD * 2}" height="${TILE + PAD * 2 + 26}" rx="34" fill="none"`;
return `<svg class="vpc" id="vpc" viewBox="0 0 ${CARD_W} ${CARD_H}" xmlns="http://www.w3.org/2000/svg">
<defs><mask id="vpc-mask"><rect id="vpc-draw" ${geom} stroke="#FFFFFF" stroke-width="12"/></mask></defs>
<rect ${geom} stroke="${PINE}" stroke-width="3" stroke-dasharray="14 12" mask="url(#vpc-mask)"/>
<g id="vpc-label">
<rect x="${CARD_W / 2 - 92}" y="${rowY - PAD - 44}" width="184" height="36" fill="${PAPER}"/>
<text x="${CARD_W / 2}" y="${rowY - PAD - 16}" text-anchor="middle" font-family="Manrope, sans-serif" font-size="30" font-weight="500" fill="${PINE}">Your VPC</text>
</g>
</svg>`;
})()
: "";

// Diagram geometry in card pixels.
const num = (x: number, y: number, n: number, stroke = PINE) =>
`<circle cx="${x}" cy="${y}" r="22" fill="${PAPER}" stroke="${stroke}" stroke-width="2.5"/>
<text x="${x}" y="${y + 9}" text-anchor="middle" font-size="26" font-weight="600" fill="${stroke}">${n}</text>`;
// Animation: every frame is a pure function of t (seconds), driven from
// Playwright in --frames mode. Stills never call render().
const script = `<script>
const clamp = (x) => Math.min(1, Math.max(0, x));
const outCubic = (x) => 1 - Math.pow(1 - x, 3);
const outBack = (x) => { const c1 = 1.4, c3 = c1 + 1; return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2); };
const seg = (t, a, d) => clamp((t - a) / d);
window.render = (t) => {
const fade = 1 - outCubic(seg(t, 4.9, 0.55));
const h = outCubic(seg(t, 0.0, 0.6));
for (const id of ["eyebrow", "lockup"]) {
const el = document.getElementById(id);
el.style.opacity = String(h * fade);
el.style.transform = "translateY(" + (1 - h) * 26 + "px)";
}
Array.from(document.querySelectorAll(".anim-tile")).forEach((el, i) => {
const p = seg(t, 0.55 + i * 0.16, 0.5);
el.style.opacity = String(outCubic(p) * fade);
el.style.transform = "scale(" + (0.6 + 0.4 * outBack(p)) + ")";
});
const vpc = document.getElementById("vpc");
if (vpc) {
vpc.style.opacity = String(fade);
const draw = document.getElementById("vpc-draw");
const len = draw.getTotalLength();
const p = outCubic(seg(t, 1.3, 1.1));
draw.setAttribute("stroke-dasharray", String(len));
draw.setAttribute("stroke-dashoffset", String(len * (1 - p)));
document.getElementById("vpc-label").style.opacity = String(outCubic(seg(t, 2.2, 0.4)));
}
};
</script>`;

return `<!doctype html><html><head><meta charset="utf-8"><style>
@font-face { font-family: "Manrope"; src: url("data:font/woff2;base64,${font.toString("base64")}") format("woff2"); font-weight: 200 800; }
* { box-sizing: border-box; }
html, body { margin: 0; background: ${PAPER}; }
.stage { position: relative; width: ${CARD_W}px; height: ${CARD_H}px; overflow: hidden; background: ${PAPER}; }
.card { position: absolute; left: 0; top: 0; width: ${CARD_W}px; height: ${CARD_H}px; font-family: "Manrope", sans-serif; color: ${INK}; }
/* No title on the hero; the post title carries it. The diagram is drawn
in the coordinates it had under the title and recentred here. */
.scene { position: absolute; inset: 0; transform: translateY(-68px); }
svg.diagram { position: absolute; left: 0; top: 0; width: ${CARD_W}px; height: ${CARD_H}px; font-family: "Manrope", sans-serif; }
.logo { position: absolute; display: flex; align-items: center; justify-content: center; }
.logo svg { display: block; height: 100%; width: auto; }
</style></head><body><div class="stage" id="stage"><div class="card" id="card"><div class="scene">
<svg class="diagram" viewBox="0 0 ${CARD_W} ${CARD_H}" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="ah-pine" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0 0L10 5L0 10z" fill="${PINE}"/></marker>
<marker id="ah-accent" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0 0L10 5L0 10z" fill="${ACCENT}"/></marker>
</defs>
<g fill="${INK}">
<!-- Your VPC -->
<rect x="944" y="300" width="900" height="560" rx="30" fill="${SAGE}" fill-opacity="0.18" stroke="${PINE}" stroke-width="3" stroke-dasharray="16 12"/>
<text x="992" y="366" font-size="32" font-weight="500" fill="${PINE}">Your VPC</text>

<!-- Rivet Cloud -->
<rect x="204" y="490" width="380" height="180" rx="22" fill="${INK}"/>
<text x="394" y="642" text-anchor="middle" font-size="38" font-weight="600" fill="#FFFFFF">Rivet Cloud</text>

<!-- Outbound only -->
<path d="M944 580 H590" fill="none" stroke="${ACCENT}" stroke-width="4" marker-end="url(#ah-accent)"/>
<text x="767" y="548" text-anchor="middle" font-size="26" font-weight="500" fill="${ACCENT}">Outbound only</text>
<text x="767" y="622" text-anchor="middle" font-size="24" fill="${INK_SOFT}">Updates and status</text>

<!-- Deployment inside the VPC -->
<rect x="1024" y="420" width="740" height="380" rx="22" fill="#FFFFFF" fill-opacity="0.7" stroke="${PINE}" stroke-width="2"/>
<rect x="1064" y="460" width="660" height="88" rx="16" fill="#FFFFFF" stroke="${INK}" stroke-width="2.5"/>
<text x="1394" y="516" text-anchor="middle" font-size="34" font-weight="600">Rivet operator</text>
<rect x="1064" y="572" width="660" height="88" rx="16" fill="${PINE}"/>
<text x="1394" y="628" text-anchor="middle" font-size="34" font-weight="600" fill="${CREAM}">Rivet control plane</text>
<rect x="1064" y="684" width="660" height="88" rx="16" fill="#FFFFFF" stroke="${INK}" stroke-width="2.5"/>
<text x="1394" y="740" text-anchor="middle" font-size="34" font-weight="600">FoundationDB</text>
<text x="1394" y="836" text-anchor="middle" font-size="24" fill="${INK_SOFT}">Managed by Rivet</text>
</g>
</svg>
<!-- Rivet badge on the Rivet Cloud card -->
<div class="logo" style="left:364px;top:522px;width:60px;height:60px">${badge}</div>
<!-- Provider marks, top-right of the VPC -->
<div class="logo" style="left:1636px;top:330px;height:48px">${aws}</div>
<div class="logo" style="left:1748px;top:330px;height:48px">${gcp}</div>
</div></div></div></body></html>`;
.eyebrow { position: absolute; top: 238px; left: 0; right: 0; margin: 0; text-align: center; font-size: 44px; line-height: 1; font-weight: 500; color: ${INK_SOFT}; }
.lockup { position: absolute; top: 322px; left: 0; right: 0; display: flex; align-items: center; justify-content: center; gap: 56px; }
.badge { width: 186px; height: 186px; border-radius: 34.375%; background: ${INK}; display: flex; align-items: center; justify-content: center; }
.badge svg { display: block; width: 100%; height: 100%; }
h1 { margin: 0; font-size: 156px; line-height: 1; letter-spacing: -0.015em; font-weight: 500; }
.tile { position: absolute; border-radius: 30%; background: #FFFFFF; border: 2px solid rgba(27, 25, 22, 0.1); display: flex; align-items: center; justify-content: center; }
.mark { display: flex; align-items: center; justify-content: center; }
.mark svg { display: block; width: 100%; height: 100%; }
svg.vpc { position: absolute; left: 0; top: 0; width: ${CARD_W}px; height: ${CARD_H}px; }
.card { transform-origin: 0 0; }
</style></head><body><div class="stage" id="stage"><div class="card" id="card">
<p class="eyebrow" id="eyebrow">Introducing</p>
<div class="lockup" id="lockup"><div class="badge">${badge}</div><h1>Rivet BYOC</h1></div>
${vpcHtml}
${tileHtml}
</div></div>${script}</body></html>`;
}

function parseOutputDir(argv: string[]): string {
function parseArgs(argv: string[]): { outputDir: string; vpc: boolean; frames?: string } {
const args = argv[0] === "--" ? argv.slice(1) : argv;
const index = args.indexOf("--output-dir");
const value = index >= 0 ? args[index + 1] : undefined;
if (!value) throw new Error("Usage: pnpm render-byoc-hero -- --output-dir <path>");
return path.resolve(value);
const opt = (flag: string) => {
const index = args.indexOf(flag);
return index >= 0 ? args[index + 1] : undefined;
};
const value = opt("--output-dir");
if (!value) throw new Error("Usage: pnpm render-byoc-hero -- --output-dir <path> [--vpc] [--frames <dir>]");
const frames = opt("--frames");
return { outputDir: path.resolve(value), vpc: args.includes("--vpc"), frames: frames ? path.resolve(frames) : undefined };
}

// 1920x1080 frames for the GIF/MP4: the 2048x1024 card scaled to 1920 wide and
// centered vertically. Encode with ffmpeg afterwards (see SKILL.md).
async function renderFrames(html: string, dir: string) {
const W = 1920;
const H = 1080;
const FPS = 30;
const DURATION = 5.6;
await mkdir(dir, { recursive: true });
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
await page.setContent(html, { waitUntil: "load" });
await page.evaluate(() => document.fonts.ready);
await page.evaluate(({ w, h }) => {
const scale = w / 2048;
const stage = document.getElementById("stage")!;
stage.style.width = w + "px";
stage.style.height = h + "px";
const card = document.getElementById("card")!;
card.style.top = Math.round((h - 1024 * scale) / 2) + "px";
card.style.transform = "scale(" + scale + ")";
}, { w: W, h: H });
const frames = Math.round(DURATION * FPS);
for (let i = 0; i < frames; i++) {
await page.evaluate((t) => (window as unknown as { render: (t: number) => void }).render(t), i / FPS);
await page.screenshot({ path: path.join(dir, `f${String(i).padStart(4, "0")}.png`) });
}
await browser.close();
console.log(`wrote ${frames} frames to ${dir}`);
}

async function main() {
const OUT = parseOutputDir(process.argv.slice(2));
const { outputDir: OUT, vpc, frames } = parseArgs(process.argv.slice(2));
await mkdir(OUT, { recursive: true });
const html = await buildHtml();
const html = await buildHtml(vpc);
await writeFile(path.join(OUT, "scene.html"), html);
if (frames) return renderFrames(html, frames);
const browser = await chromium.launch();
for (const target of [
{ name: "image", w: 2048, h: 1024 },
Expand Down
Loading
Loading