All files / src/lib og-card.ts

100% Statements 28/28
90.9% Branches 10/11
100% Functions 5/5
100% Lines 27/27

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89                        2x                 2x     2x       197x 197x 197x 852x 44x 44x   808x     197x 197x         96x 96x 96x 2x 2x   96x 96x 96x 96x   96x   384x 384x         96x                     126x           104x                
/**
 * The Shade Card social preview (1200×630): a black cover board with an
 * index-red tab on card stock, beside a laminate-teal shade strip. One
 * template for the /og/[type].svg route and scripts/generate-og-images.ts,
 * which rasterises it to the public/og/*.png files pages reference.
 *
 * Colours are the light-theme values from src/styles/global.css (an image
 * cannot read CSS custom properties). Type is a system sans: resvg cannot
 * read Anek Latin's woff2, and it renders only exact weights (700, not 760).
 */
import { escapeXml } from "./og-utils";
 
const C = {
	ground: "#EFF0EB", // --background-base, card stock
	cover: "#141615", // --cover
	onCover: "#EFF0EB", // --on-cover
	onCoverMuted: "#B4B9B3", // --on-cover-muted
	index: "#D23A1F", // --index
	strip: ["#D3E6DF", "#79AC9E", "#2F6F62", "#1C4C43"], // --primary-100/300/500/700
};
 
const FONT = "'Helvetica Neue', Helvetica, Arial, 'DejaVu Sans', sans-serif";
 
const LOGO_PATH =
	"M 66.42 118.08 C66.19,117.85 66.00,108.29 66.00,96.83 L 66.00 76.00 L 62.93 76.00 C60.57,76.00 59.93,75.60 60.18,74.29 C60.43,73.01 62.73,72.14 69.50,70.78 C74.44,69.79 79.05,69.19 79.74,69.46 C80.70,69.82 81.00,75.31 81.00,92.47 L 81.00 115.00 L 102.50 115.00 L 124.00 115.00 L 124.00 86.54 L 124.00 58.09 L 99.25 41.08 C85.64,31.72 73.78,24.05 72.90,24.03 C71.33,24.00 27.69,53.24 23.23,57.31 C20.97,59.37 20.97,59.42 21.23,86.94 L 21.50 114.50 L 38.25 114.77 C52.23,115.00 55.00,115.29 55.00,116.52 C55.00,117.77 52.28,118.00 37.62,118.00 C24.94,118.00 19.97,117.66 19.21,116.75 C17.96,115.23 17.11,58.10 18.32,56.24 C18.77,55.54 21.69,53.26 24.82,51.17 C30.27,47.52 37.14,42.82 59.91,27.16 C65.88,23.06 71.37,20.00 72.78,20.00 C74.13,20.00 77.32,21.50 79.87,23.34 C82.41,25.18 93.05,32.42 103.50,39.44 C113.95,46.46 123.74,53.51 125.25,55.11 L 128.00 58.03 L 128.00 86.32 C128.00,107.40 127.68,115.04 126.75,116.29 C125.64,117.78 122.23,117.99 96.17,118.23 C80.03,118.38 66.64,118.31 66.42,118.08 ZM 70.00 63.33 C69.18,63.01 67.26,61.60 65.75,60.19 C62.00,56.69 61.86,50.05 65.45,46.45 C68.83,43.08 75.75,43.02 79.59,46.33 C83.41,49.61 83.62,56.83 80.00,60.45 C77.44,63.01 72.69,64.38 70.00,63.33 Z";
 
/** Greedy word wrap by character count; the last line takes any overflow. */
export function wrapLines(text: string, maxChars: number, maxLines: number): string[] {
	const lines: string[] = [];
	let line = "";
	for (const word of text.split(/\s+/).filter(Boolean)) {
		if (line && `${line} ${word}`.length > maxChars && lines.length < maxLines - 1) {
			lines.push(line);
			line = word;
		} else {
			line = line ? `${line} ${word}` : word;
		}
	}
	Eif (line) lines.push(line);
	return lines;
}
 
export function shadeCardSvg({ title, subtitle }: { title: string; subtitle: string }): string {
	// Cover board: x 64–824, y 88–566. Text column: x 104–784.
	let size = 60;
	let lines = wrapLines(title, 22, 3);
	if (lines.length > 2) {
		size = 52;
		lines = wrapLines(title, 26, 3);
	}
	const lead = Math.round(size * 1.04);
	const titleTop = 214 + size;
	const subLines = wrapLines(subtitle, 46, 2);
	const subTop = titleTop + (lines.length - 1) * lead + 56;
 
	const chips = C.strip
		.map((fill, i) => {
			const x = 856 + i * 72;
			return `<rect x="${x}" y="88" width="64" height="478" rx="4" fill="${fill}" stroke="rgba(20,22,21,0.08)"/>
  <circle cx="${x + 16}" cy="104" r="6" fill="${C.ground}"/>`;
		})
		.join("\n  ");
 
	return `<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
  <rect width="1200" height="630" fill="${C.ground}"/>
  <path d="M112 88 V64 a4 4 0 0 1 4 -4 H240 a4 4 0 0 1 4 4 V88 Z" fill="${C.index}"/>
  <rect x="64" y="88" width="760" height="478" rx="4" fill="${C.cover}"/>
  <circle cx="80" cy="104" r="6" fill="${C.ground}"/>
  <g transform="translate(98, 118) scale(0.34)"><path d="${LOGO_PATH}" fill="${C.onCover}"/></g>
  <text x="150" y="152" font-size="30" font-weight="700" font-family="${FONT}" fill="${C.onCover}">Interioring</text>
  ${lines
		.map(
			(l, i) =>
				`<text x="104" y="${titleTop + i * lead}" font-size="${size}" font-weight="700" font-family="${FONT}" fill="${C.onCover}">${escapeXml(l)}</text>`,
		)
		.join("\n  ")}
  ${subLines
		.map(
			(l, i) =>
				`<text x="104" y="${subTop + i * 34}" font-size="26" font-family="${FONT}" fill="${C.onCoverMuted}">${escapeXml(l)}</text>`,
		)
		.join("\n  ")}
  <rect x="104" y="500" width="680" height="1" fill="${C.onCover}" fill-opacity="0.2"/>
  <text x="104" y="534" font-size="17" letter-spacing="2.4" font-family="${FONT}" fill="${C.onCoverMuted}">INTERIORING.COM</text>
  ${chips}
</svg>`;
}