Skip to content
Open
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
1,182 changes: 1,182 additions & 0 deletions docs/superpowers/plans/marketing/2026-05-17-brand-assets.md

Large diffs are not rendered by default.

323 changes: 323 additions & 0 deletions docs/superpowers/specs/marketing/2026-05-17-brand-assets-design.md

Large diffs are not rendered by default.

60 changes: 60 additions & 0 deletions marketing/assets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# @threadplane/marketing-assets

Branded social-card rendering for the marketing pipeline. `renderCard()` turns typed input into a PNG via satori (JSX→SVG) + @resvg/resvg-js (SVG→PNG). No Next.js dependency — runs anywhere Node does.

## Usage

```ts
import { renderCard } from '@threadplane/marketing-assets';

const card = await renderCard({
template: 'x-card',
title: 'Build a streaming chat UI in Angular with LangGraph',
subtitle: 'Signal-native streaming, wired to a LangGraph backend.',
});
// card.png is a Buffer; card.width / card.height / card.contentType describe it.
```

The X channel adapter embeds `card.png` directly in `Draft.media`.

## Templates

| id | size | use |
|----|------|-----|
| `x-card` | 1200×675 | X in-stream image (16:9) |
| `og-card` | 1200×630 | standard OpenGraph / Dev.to cover |

Both share `CardShell`: eyebrow, Garamond headline, optional subtitle, footer with trust pills (or author byline) + the plane logo wordmark.

## Input

- `title` (required) — headline
- `subtitle` — supporting line
- `eyebrow` — kicker; defaults to "Agent UI for Angular · MIT"
- `author` — `{ name, role? }`; when set, replaces the trust pills

## Assets

- Fonts: bundled static TTFs in `fonts/` (Garamond 700, Inter 400/600). No runtime fetch.
- Logo: `brand/plane.png`, rendered as an `<img>` data-URI (satori can't render the emoji glyph).
- Both dirs are copied into `dist/` by the Nx build assets array.

## Adding a template

1. Add a TSX wrapper in `src/templates/` calling `CardShell` with size params.
2. Register it in `src/templates/registry.ts` with width/height.
3. Add its id to `TemplateId` in `src/types.ts`.
4. Add a sample to `scripts/preview.ts`.

## Preview

```bash
npx tsx --tsconfig marketing/assets/tsconfig.lib.json marketing/assets/scripts/preview.ts
```

The `--tsconfig` flag points tsx at the JSX runtime config for the `.tsx` templates (the workspace-root tsconfig has no `jsx` setting). Writes sample PNGs to `marketing/assets/preview/` (gitignored). Open them to eyeball layout/fonts/logo.

## See also

- Spec: `docs/superpowers/specs/marketing/2026-05-17-brand-assets-design.md`
- Meta: `docs/superpowers/specs/marketing/2026-05-17-marketing-meta-design.md`
Binary file added marketing/assets/brand/plane.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added marketing/assets/fonts/EBGaramond-Bold.ttf
Binary file not shown.
Binary file added marketing/assets/fonts/Inter-Regular.ttf
Binary file not shown.
Binary file added marketing/assets/fonts/Inter-SemiBold.ttf
Binary file not shown.
7 changes: 6 additions & 1 deletion marketing/assets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,10 @@
"directory": "marketing/assets"
},
"sideEffects": false,
"private": true
"private": true,
"dependencies": {
"satori": "^0.12.0",
"@resvg/resvg-js": "^2.6.2",
"react": "^19.0.0"
}
}
2 changes: 2 additions & 0 deletions marketing/assets/preview/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.png
!.gitkeep
Empty file.
12 changes: 11 additions & 1 deletion marketing/assets/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,17 @@
"options": {
"outputPath": "dist/marketing/assets",
"main": "marketing/assets/src/index.ts",
"tsConfig": "marketing/assets/tsconfig.lib.json"
"tsConfig": "marketing/assets/tsconfig.lib.json",
"assets": [
{ "input": "marketing/assets/fonts", "glob": "**/*", "output": "fonts" },
{ "input": "marketing/assets/brand", "glob": "**/*", "output": "brand" }
]
}
},
"test": {
"executor": "@nx/vitest:test",
"options": {
"configFile": "marketing/assets/vite.config.mts"
}
},
"lint": {
Expand Down
50 changes: 50 additions & 0 deletions marketing/assets/scripts/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Renders sample cards to marketing/assets/preview/ for manual eyeballing.
// Run: npx tsx --tsconfig marketing/assets/tsconfig.lib.json marketing/assets/scripts/preview.ts
// (the --tsconfig flag points tsx at the JSX runtime config for the .tsx templates)
import { writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { renderCard, type CardInput } from '../src';

const samples: { name: string; input: CardInput }[] = [
{
name: 'x-card-basic',
input: { template: 'x-card', title: 'Build a streaming chat UI in Angular with LangGraph' },
},
{
name: 'x-card-subtitle',
input: {
template: 'x-card',
title: 'Build a streaming chat UI in Angular',
subtitle: 'Signal-native streaming, wired to a LangGraph backend.',
},
},
{
name: 'og-card-basic',
input: { template: 'og-card', title: 'Agent UI for Angular' },
},
{
name: 'og-card-author',
input: {
template: 'og-card',
title: 'Notes from Cacheplane',
subtitle: 'Production patterns for agent UI.',
author: { name: 'Brian Love', role: 'Founder, Cacheplane' },
},
},
];

async function main(): Promise<void> {
const outDir = join(process.cwd(), 'marketing', 'assets', 'preview');
await mkdir(outDir, { recursive: true });
for (const s of samples) {
const card = await renderCard(s.input);
const file = join(outDir, `${s.name}.png`);
await writeFile(file, card.png);
console.log(`wrote ${file} (${card.width}x${card.height}, ${card.png.byteLength} bytes)`);
}
}

main().catch((err) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
});
16 changes: 16 additions & 0 deletions marketing/assets/src/brand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: MIT
//
// Palette + wordmark lifted verbatim from apps/website/src/app/opengraph-image.tsx
// so marketing cards and the site share one visual language. The plane logo is
// NOT here — it's the bundled brand/plane.png, loaded via logo.ts.
export const brand = {
gradient: 'linear-gradient(135deg, #fafbfc 0%, #eaf3ff 100%)',
ink: '#1a1a2e',
inkSoft: '#555770',
accent: '#004090',
angular: '#DD0031',
wordmark: 'cacheplane.ai',
serif: 'EB Garamond, Georgia, serif',
sans: 'Inter, sans-serif',
defaultEyebrow: 'Agent UI for Angular · MIT',
} as const;
23 changes: 23 additions & 0 deletions marketing/assets/src/fonts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { loadFonts } from './fonts';

describe('loadFonts', () => {
it('returns three font entries with expected names + weights', async () => {
const fonts = await loadFonts();
expect(fonts).toHaveLength(3);
const byName = fonts.map((f) => `${f.name}:${f.weight}`);
expect(byName).toContain('EB Garamond:700');
expect(byName).toContain('Inter:400');
expect(byName).toContain('Inter:600');
for (const f of fonts) {
expect(f.data.byteLength).toBeGreaterThan(1000);
expect(f.style).toBe('normal');
}
});

it('memoizes — second call returns the same array reference', async () => {
const a = await loadFonts();
const b = await loadFonts();
expect(a).toBe(b);
});
});
30 changes: 30 additions & 0 deletions marketing/assets/src/fonts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

export interface SatoriFont {
name: string;
data: Buffer;
weight: 400 | 600 | 700;
style: 'normal';
}

let cached: SatoriFont[] | null = null;

export async function loadFonts(): Promise<SatoriFont[]> {
if (cached) return cached;
const here = dirname(fileURLToPath(import.meta.url));
const fontsDir = join(here, '..', 'fonts');
const [garamond, interReg, interSemi] = await Promise.all([
readFile(join(fontsDir, 'EBGaramond-Bold.ttf')),
readFile(join(fontsDir, 'Inter-Regular.ttf')),
readFile(join(fontsDir, 'Inter-SemiBold.ttf')),
]);
cached = [
{ name: 'EB Garamond', data: garamond, weight: 700, style: 'normal' },
{ name: 'Inter', data: interReg, weight: 400, style: 'normal' },
{ name: 'Inter', data: interSemi, weight: 600, style: 'normal' },
];
return cached;
}
27 changes: 4 additions & 23 deletions marketing/assets/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,6 @@
// SPDX-License-Identifier: MIT
//
// @threadplane/marketing-assets — Brand asset rendering for the marketing pipeline.
// Skeleton only. Implementation lands in the brand-assets sub-spec.

export interface CardInput {
template: string;
title: string;
subtitle?: string;
tag?: string;
author?: { name: string; role?: string };
}

export interface RenderedCard {
png: Buffer;
width: number;
height: number;
contentType: 'image/png';
}

export function renderCard(_input: CardInput): Promise<RenderedCard> {
throw new Error(
'@threadplane/marketing-assets: renderCard() not yet implemented. See brand-assets sub-spec.',
);
}
// @threadplane/marketing-assets — branded social-card rendering.
// See docs/superpowers/specs/marketing/2026-05-17-brand-assets-design.md
export { renderCard } from './render';
export type { CardInput, RenderedCard, TemplateId } from './types';
19 changes: 19 additions & 0 deletions marketing/assets/src/logo.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { loadPlaneDataUri } from './logo';

describe('loadPlaneDataUri', () => {
it('returns a base64 png data URI', async () => {
const uri = await loadPlaneDataUri();
expect(uri.startsWith('data:image/png;base64,')).toBe(true);
const b64 = uri.slice('data:image/png;base64,'.length);
const bytes = Buffer.from(b64, 'base64');
// PNG magic number
expect([...bytes.subarray(0, 4)]).toEqual([0x89, 0x50, 0x4e, 0x47]);
});

it('memoizes — second call returns the same string reference', async () => {
const a = await loadPlaneDataUri();
const b = await loadPlaneDataUri();
expect(a).toBe(b);
});
});
14 changes: 14 additions & 0 deletions marketing/assets/src/logo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: MIT
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

let cached: string | null = null;

export async function loadPlaneDataUri(): Promise<string> {
if (cached) return cached;
const here = dirname(fileURLToPath(import.meta.url));
const png = await readFile(join(here, '..', 'brand', 'plane.png'));
cached = `data:image/png;base64,${png.toString('base64')}`;
return cached;
}
54 changes: 54 additions & 0 deletions marketing/assets/src/render.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { renderCard } from './render';

function readPngDimensions(buf: Buffer): { width: number; height: number } {
// PNG IHDR: width = bytes 16-19 big-endian, height = 20-23.
expect([...buf.subarray(0, 4)]).toEqual([0x89, 0x50, 0x4e, 0x47]);
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}

describe('renderCard', () => {
it('renders an x-card at 1200x675', async () => {
const card = await renderCard({ template: 'x-card', title: 'Hello world' });
expect(card.contentType).toBe('image/png');
expect(card.png.byteLength).toBeGreaterThan(1000);
expect(readPngDimensions(card.png)).toEqual({ width: 1200, height: 675 });
expect(card.width).toBe(1200);
expect(card.height).toBe(675);
});

it('renders an og-card at 1200x630', async () => {
const card = await renderCard({ template: 'og-card', title: 'Hello world' });
expect(readPngDimensions(card.png)).toEqual({ width: 1200, height: 630 });
});

it('renders with a subtitle', async () => {
const card = await renderCard({
template: 'x-card',
title: 'Streaming chat in Angular',
subtitle: 'A signal-native tutorial with LangGraph.',
});
expect(card.png.byteLength).toBeGreaterThan(1000);
});

it('renders with an author (replaces trust pills)', async () => {
const card = await renderCard({
template: 'og-card',
title: 'Build agent UI',
author: { name: 'Brian Love', role: 'Founder' },
});
expect(card.png.byteLength).toBeGreaterThan(1000);
});

it('renders title-only (default eyebrow + trust pills path)', async () => {
const card = await renderCard({ template: 'og-card', title: 'Just a title' });
expect(card.png.byteLength).toBeGreaterThan(1000);
});

it('throws on unknown template id', async () => {
await expect(
// @ts-expect-error testing runtime guard with an invalid id
renderCard({ template: 'nope', title: 'x' }),
).rejects.toThrow(/Unknown template "nope"/);
});
});
35 changes: 35 additions & 0 deletions marketing/assets/src/render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: MIT
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
import { loadFonts } from './fonts';
import { loadPlaneDataUri } from './logo';
import { TEMPLATES } from './templates/registry';
import type { CardInput, RenderedCard } from './types';

export async function renderCard(input: CardInput): Promise<RenderedCard> {
const entry = TEMPLATES[input.template];
if (!entry) {
throw new Error(
`Unknown template "${input.template}". Known: ${Object.keys(TEMPLATES).join(', ')}.`,
);
}
const [fonts, planeDataUri] = await Promise.all([loadFonts(), loadPlaneDataUri()]);
const svg = await satori(entry.component(input, { planeDataUri }), {
width: entry.width,
height: entry.height,
fonts: fonts.map((f) => ({
name: f.name,
data: f.data,
weight: f.weight,
style: f.style,
})),
});
const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: entry.width } });
const png = resvg.render().asPng();
return {
png: Buffer.from(png),
width: entry.width,
height: entry.height,
contentType: 'image/png',
};
}
Loading
Loading