From a48a724573e902b08d6e10e16b9203ebce4a710d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 20:21:32 +0100 Subject: [PATCH] docs: add social preview card --- CHANGELOG.md | 7 + scripts/build-docs-site.mjs | 10 +- scripts/social-card.mjs | 293 ++++++++++++++++++++++++++++++++++++ 3 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 scripts/social-card.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 25fec33333..7ddf71e7ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable ClawSweeper changes are tracked here. This file was reconstructed from first-parent git history. Generated dashboard, checkpoint, and status-only commits are intentionally omitted. +## Unreleased + +### Added + +- Added a generated 1200x630 social preview card plus large-image Open Graph and + Twitter metadata for the docs site. + ## 0.2.0 - 2026-05-03 ### Added diff --git a/scripts/build-docs-site.mjs b/scripts/build-docs-site.mjs index 084b679c49..0da2cc9e43 100644 --- a/scripts/build-docs-site.mjs +++ b/scripts/build-docs-site.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; +import { socialCardPng } from "./social-card.mjs"; const root = process.cwd(); const docsDir = path.join(root, "docs"); @@ -80,6 +81,7 @@ for (const page of pages) { fs.writeFileSync(path.join(outDir, "clawsweeper.svg"), clawSvg(), "utf8"); fs.writeFileSync(path.join(outDir, "favicon.svg"), faviconSvg(), "utf8"); +fs.writeFileSync(path.join(outDir, "social-card.png"), socialCardPng()); fs.writeFileSync(path.join(outDir, ".nojekyll"), "", "utf8"); fs.writeFileSync(path.join(outDir, "CNAME"), `${customDomain}\n`, "utf8"); fs.writeFileSync( @@ -325,7 +327,13 @@ function layout({ page, html, toc, prev, next, sectionName }) { - + + + + + + + diff --git a/scripts/social-card.mjs b/scripts/social-card.mjs new file mode 100644 index 0000000000..e9b3d1c4c2 --- /dev/null +++ b/scripts/social-card.mjs @@ -0,0 +1,293 @@ +import zlib from "node:zlib"; + +const WIDTH = 1200; +const HEIGHT = 630; + +const COLORS = { + ink: "#06181c", + paper: "#fdf6e9", + shell: "#f4ead7", + reef: "#0b3a3f", + tide: "#0a6a72", + kelp: "#13848e", + coral: "#ec5b3c", + crab: "#d9472b", + crabDark: "#7d2613", + sun: "#f4a93a", + sand: "#e9d7b1", +}; + +const FONT = { + A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], + B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"], + C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"], + D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], + E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], + F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"], + G: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"], + H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"], + I: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"], + J: ["00111", "00010", "00010", "00010", "00010", "10010", "01100"], + K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"], + L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"], + M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"], + N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"], + O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"], + P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"], + Q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101"], + R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"], + S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"], + T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"], + U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"], + V: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"], + W: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"], + X: ["10001", "10001", "01010", "00100", "01010", "10001", "10001"], + Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"], + Z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"], + 0: ["01110", "10001", "10011", "10101", "11001", "10001", "01110"], + 1: ["00100", "01100", "00100", "00100", "00100", "00100", "01110"], + 2: ["01110", "10001", "00001", "00010", "00100", "01000", "11111"], + 3: ["11110", "00001", "00001", "01110", "00001", "00001", "11110"], + 4: ["10010", "10010", "10010", "11111", "00010", "00010", "00010"], + 5: ["11111", "10000", "10000", "11110", "00001", "00001", "11110"], + 6: ["01110", "10000", "10000", "11110", "10001", "10001", "01110"], + 7: ["11111", "00001", "00010", "00100", "01000", "01000", "01000"], + 8: ["01110", "10001", "10001", "01110", "10001", "10001", "01110"], + 9: ["01110", "10001", "10001", "01111", "00001", "00001", "01110"], + ".": ["00000", "00000", "00000", "00000", "00000", "01100", "01100"], + " ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"], + "-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"], +}; + +export function socialCardPng() { + const card = new Raster(WIDTH, HEIGHT); + card.background(); + + card.roundRect(68, 66, 680, 498, 30, rgba(COLORS.paper, 0.96)); + card.roundRect(82, 80, 652, 470, 22, rgba("#f7ecd3", 0.94)); + card.rect(82, 532, 652, 16, rgba(COLORS.coral, 1)); + card.rect(82, 516, 424, 16, rgba(COLORS.sun, 1)); + card.rect(506, 516, 228, 16, rgba(COLORS.kelp, 1)); + + card.text("OPENCLAW MAINTENANCE BOT", 112, 112, 4, COLORS.coral, 1); + card.text("CLAW", 110, 166, 22, COLORS.ink, 1); + card.text("SWEEPER", 112, 330, 14, COLORS.reef, 1); + card.text("REVIEW APPLY REPAIR COMMIT", 112, 478, 4, COLORS.tide, 0); + + card.roundRect(804, 96, 310, 88, 24, rgba(COLORS.paper, 0.16)); + card.text("FOUR", 838, 128, 6, COLORS.paper, 1); + card.text("LANES", 1002, 128, 6, COLORS.sun, 1); + + card.roundRect(790, 220, 352, 260, 32, rgba(COLORS.paper, 0.1)); + card.lobster(966, 332, 1.02); + + card.roundRect(806, 506, 318, 52, 18, rgba(COLORS.ink, 0.36)); + card.text("CLAW SWEEP", 846, 522, 5, COLORS.paper, 1); + + card.text("CLAWSWEEPER.BOT", 812, 574, 4, COLORS.sand, 1); + return encodePng(card.width, card.height, card.data); +} + +class Raster { + constructor(width, height) { + this.width = width; + this.height = height; + this.data = Buffer.alloc(width * height * 4); + } + + background() { + const top = rgb(COLORS.reef); + const bottom = rgb(COLORS.ink); + for (let y = 0; y < this.height; y++) { + const t = y / (this.height - 1); + const row = mix(top, bottom, t); + for (let x = 0; x < this.width; x++) { + const wave = Math.sin((x + y * 0.8) / 34) * 4 + Math.sin((x - y * 1.4) / 91) * 6; + const grain = ((x * 17 + y * 31) % 23) - 11; + this.raw(x, y, row[0] + wave + grain, row[1] + wave * 0.8, row[2] + wave * 0.55, 255); + } + } + for (let y = 0; y < this.height; y += 38) + this.line(0, y, this.width, y + 110, rgba("#ffffff", 0.025), 2); + this.circle(1088, 86, 132, rgba(COLORS.coral, 0.13)); + this.circle(1016, 594, 220, rgba(COLORS.tide, 0.2)); + this.circle(760, -40, 180, rgba(COLORS.sun, 0.12)); + } + + lobster(cx, cy, scale) { + const s = scale; + const crab = rgba(COLORS.crab, 1); + const dark = rgba(COLORS.crabDark, 1); + const ink = rgba(COLORS.ink, 1); + this.line(cx - 76 * s, cy + 28 * s, cx - 154 * s, cy + 82 * s, dark, 12 * s); + this.line(cx + 76 * s, cy + 28 * s, cx + 154 * s, cy + 82 * s, dark, 12 * s); + this.circle(cx - 170 * s, cy + 88 * s, 34 * s, crab); + this.circle(cx + 170 * s, cy + 88 * s, 34 * s, crab); + this.circle(cx - 182 * s, cy + 76 * s, 20 * s, crab); + this.circle(cx + 182 * s, cy + 76 * s, 20 * s, crab); + this.circle(cx, cy + 38 * s, 88 * s, crab); + this.ellipse(cx, cy + 30 * s, 98 * s, 64 * s, crab); + this.line(cx - 54 * s, cy - 22 * s, cx - 54 * s, cy - 58 * s, ink, 4 * s); + this.line(cx + 54 * s, cy - 22 * s, cx + 54 * s, cy - 58 * s, ink, 4 * s); + this.circle(cx - 54 * s, cy - 66 * s, 12 * s, rgba(COLORS.paper, 1)); + this.circle(cx + 54 * s, cy - 66 * s, 12 * s, rgba(COLORS.paper, 1)); + this.circle(cx - 50 * s, cy - 66 * s, 4 * s, ink); + this.circle(cx + 58 * s, cy - 66 * s, 4 * s, ink); + this.line(cx - 48 * s, cy + 72 * s, cx + 48 * s, cy + 72 * s, rgba(COLORS.paper, 0.45), 4 * s); + this.line(cx - 24 * s, cy + 95 * s, cx + 24 * s, cy + 95 * s, ink, 4 * s); + + for (const dir of [-1, 1]) { + this.line(cx + dir * 62 * s, cy + 85 * s, cx + dir * 120 * s, cy + 150 * s, ink, 6 * s); + this.line(cx + dir * 28 * s, cy + 96 * s, cx + dir * 46 * s, cy + 170 * s, ink, 6 * s); + } + this.line(cx + 168 * s, cy + 104 * s, cx + 216 * s, cy + 186 * s, rgba("#8a5a2c", 1), 7 * s); + this.roundRect(cx + 196 * s, cy + 180 * s, 42 * s, 48 * s, 8 * s, rgba(COLORS.sun, 1)); + for (let i = 0; i < 5; i++) { + this.line( + cx + (202 + i * 8) * s, + cy + 188 * s, + cx + (194 + i * 12) * s, + cy + 226 * s, + rgba("#8a5a2c", 1), + 2 * s, + ); + } + } + + text(text, x, y, scale, color, gap = 1) { + let cursor = x; + for (const char of text.toUpperCase()) { + const glyph = FONT[char] || FONT[" "]; + for (let gy = 0; gy < glyph.length; gy++) { + for (let gx = 0; gx < glyph[gy].length; gx++) { + if (glyph[gy][gx] === "1") + this.rect(cursor + gx * scale, y + gy * scale, scale, scale, rgba(color, 1)); + } + } + cursor += 5 * scale + gap * scale; + } + } + + raw(x, y, r, g, b, a) { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) return; + const i = (Math.floor(y) * this.width + Math.floor(x)) * 4; + this.data[i] = clamp(r); + this.data[i + 1] = clamp(g); + this.data[i + 2] = clamp(b); + this.data[i + 3] = clamp(a); + } + + pixel(x, y, color) { + if (x < 0 || y < 0 || x >= this.width || y >= this.height) return; + const i = (Math.floor(y) * this.width + Math.floor(x)) * 4; + const a = color[3] / 255; + this.data[i] = clamp(color[0] * a + this.data[i] * (1 - a)); + this.data[i + 1] = clamp(color[1] * a + this.data[i + 1] * (1 - a)); + this.data[i + 2] = clamp(color[2] * a + this.data[i + 2] * (1 - a)); + this.data[i + 3] = 255; + } + + rect(x, y, w, h, color) { + const x0 = Math.max(0, Math.floor(x)); + const y0 = Math.max(0, Math.floor(y)); + const x1 = Math.min(this.width, Math.ceil(x + w)); + const y1 = Math.min(this.height, Math.ceil(y + h)); + for (let yy = y0; yy < y1; yy++) for (let xx = x0; xx < x1; xx++) this.pixel(xx, yy, color); + } + + roundRect(x, y, w, h, r, color) { + this.rect(x + r, y, w - 2 * r, h, color); + this.rect(x, y + r, w, h - 2 * r, color); + this.circle(x + r, y + r, r, color); + this.circle(x + w - r, y + r, r, color); + this.circle(x + r, y + h - r, r, color); + this.circle(x + w - r, y + h - r, r, color); + } + + circle(cx, cy, r, color) { + this.ellipse(cx, cy, r, r, color); + } + + ellipse(cx, cy, rx, ry, color) { + const x0 = Math.floor(cx - rx); + const y0 = Math.floor(cy - ry); + const x1 = Math.ceil(cx + rx); + const y1 = Math.ceil(cy + ry); + for (let y = y0; y <= y1; y++) { + for (let x = x0; x <= x1; x++) { + const dx = (x - cx) / rx; + const dy = (y - cy) / ry; + if (dx * dx + dy * dy <= 1) this.pixel(x, y, color); + } + } + } + + line(x0, y0, x1, y1, color, width = 1) { + const steps = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)); + for (let i = 0; i <= steps; i++) { + const t = steps === 0 ? 0 : i / steps; + this.circle(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, width / 2, color); + } + } +} + +function encodePng(width, height, rgbaData) { + const stride = width * 4 + 1; + const raw = Buffer.alloc(stride * height); + for (let y = 0; y < height; y++) { + raw[y * stride] = 0; + rgbaData.copy(raw, y * stride + 1, y * width * 4, (y + 1) * width * 4); + } + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk("IHDR", Buffer.concat([u32(width), u32(height), Buffer.from([8, 6, 0, 0, 0])])), + chunk("IDAT", zlib.deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +function chunk(type, data) { + const name = Buffer.from(type, "ascii"); + return Buffer.concat([ + u32(data.length), + name, + data, + u32(crc32(Buffer.concat([name, data])) >>> 0), + ]); +} + +function u32(value) { + const out = Buffer.alloc(4); + out.writeUInt32BE(value >>> 0); + return out; +} + +function crc32(buffer) { + let crc = 0xffffffff; + for (const byte of buffer) { + crc ^= byte; + for (let i = 0; i < 8; i++) crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1; + } + return crc ^ 0xffffffff; +} + +function rgb(hex) { + const value = hex.replace("#", ""); + return [ + parseInt(value.slice(0, 2), 16), + parseInt(value.slice(2, 4), 16), + parseInt(value.slice(4, 6), 16), + ]; +} + +function rgba(hex, alpha) { + return [...rgb(hex), Math.round(alpha * 255)]; +} + +function mix(a, b, t) { + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; +} + +function clamp(value) { + return Math.max(0, Math.min(255, Math.round(value))); +}