diff --git a/apps/og-generator/app/api/og/route.tsx b/apps/og-generator/app/api/og/route.tsx index 5e8b3e64f6a..9e4a44862ff 100644 --- a/apps/og-generator/app/api/og/route.tsx +++ b/apps/og-generator/app/api/og/route.tsx @@ -6,20 +6,7 @@ import { getBrand, color } from '@/lib/design/brands' import { satoriFonts, measurementFont } from '@/lib/design/fonts' import { getFormat } from '@/lib/design/formats' import { iconDataUri } from '@/lib/design/icons' -import { - PATTERN_SCALE_PX, - clampPatternOpacity, - patternDataUri, - type PatternColor, - type PatternConfig, - type PatternScale, - type PatternType, -} from '@/lib/design/patterns' -import { - DEFAULT_TEMPLATE_ID, - TEMPLATE_MAP, - type TemplateDefaultPattern, -} from '@/lib/design/templates' +import { DEFAULT_TEMPLATE_ID, TEMPLATE_MAP } from '@/lib/design/templates' import { typography } from '@/lib/design/tokens' import { fitHeadline } from '@/lib/text/fit-headline' import { toSentenceCase } from '@/lib/text/sentence-case' @@ -33,6 +20,9 @@ const DEFAULT_HEADLINE = 'Postgres full text search just got faster' const HEADLINE = typography.roles.headline const EYEBROW = typography.roles.eyebrow +// One step below EYEBROW's own weight — the pill reads better slightly lighter +// than the headline at this size. +const EYEBROW_PILL_WEIGHT = 400 /** Scale (naturalW, naturalH) to fit within a boxSize square, preserving aspect ratio. */ function fitBox(naturalW: number, naturalH: number, boxSize: number): { width: number; height: number } { @@ -40,22 +30,11 @@ function fitBox(naturalW: number, naturalH: number, boxSize: number): { width: n return ratio >= 1 ? { width: boxSize, height: boxSize / ratio } : { width: boxSize * ratio, height: boxSize } } -const THUMB_PATTERN_FALLBACK: TemplateDefaultPattern = { - type: 'none', - scale: 'md', - color: 'white', - opacity: 0.06, -} - const CORS_AND_CACHE = { 'access-control-allow-origin': '*', 'cache-control': 'no-store, max-age=0', } -const PATTERN_TYPES: PatternType[] = ['grid', 'dots', 'hlines', 'vlines'] -const PATTERN_SCALES: PatternScale[] = ['sm', 'md', 'lg'] -const PATTERN_COLORS: PatternColor[] = ['white', 'green'] - export async function GET(req: Request) { try { const { searchParams } = new URL(req.url) @@ -77,35 +56,6 @@ export async function GET(req: Request) { const iconObj = iconName ? await resolveIcon(iconName, brand.id) : null const type = searchParams.get('type') === 'thumb' ? 'thumb' : 'og' - // Resolve the background pattern from query params, falling back to a default - // (the per-template default for OG, or "none" for thumb). - const resolvePattern = (fallback: TemplateDefaultPattern): PatternConfig => { - const raw = searchParams.get('pattern') - if (raw === null) return fallback - if (raw === 'none') return { ...fallback, type: 'none' } - const type = (PATTERN_TYPES as string[]).includes(raw) ? (raw as PatternType) : fallback.type - const scaleParam = searchParams.get('patternScale') - const colorParam = searchParams.get('patternColor') - const opacityParam = searchParams.get('patternOpacity') - return { - type, - scale: (PATTERN_SCALES as string[]).includes(scaleParam ?? '') - ? (scaleParam as PatternScale) - : fallback.scale, - color: (PATTERN_COLORS as string[]).includes(colorParam ?? '') - ? (colorParam as PatternColor) - : fallback.color, - opacity: opacityParam ? clampPatternOpacity(Number(opacityParam)) : fallback.opacity, - } - } - - const patternLayer = (cfg: PatternConfig, offsetX = 0, offsetY = 0) => { - if (cfg.type === 'none') return null - const uri = patternDataUri({ ...cfg, width: W, height: H, scaleFactor: s, offsetX, offsetY }) - // eslint-disable-next-line @next/next/no-img-element - return - } - // ---- Thumb variant: same canvas + icon system, no text layer (brief §3) - if (type === 'thumb') { const thumb = format.thumb ?? { default: 380, min: 160, max: 480 } @@ -113,7 +63,6 @@ export async function GET(req: Request) { const thumbSize = Number.isFinite(thumbNum) ? Math.min(thumb.max, Math.max(thumb.min, Math.round(thumbNum))) : thumb.default - const cfg = resolvePattern(THUMB_PATTERN_FALLBACK) const thumbBgImage = iconObj ? null : randomBackgroundDataUri() const thumbRoot = ( @@ -131,7 +80,6 @@ export async function GET(req: Request) { : {}), }} > - {patternLayer(cfg)} {iconObj && iconObj.kind === 'logo' && iconObj.url ? ( // Custom color logo — rendered as-is (no stroke normalization), // fit to its natural aspect ratio (brief follow-up: partnerships). @@ -161,7 +109,6 @@ export async function GET(req: Request) { headers: { ...CORS_AND_CACHE, 'x-og-template': 'thumb', - 'x-og-pattern': cfg.type, 'x-og-has-icon': String(!!iconObj), }, }) @@ -172,7 +119,6 @@ export async function GET(req: Request) { const rawHeadline = (searchParams.get('headline') ?? DEFAULT_HEADLINE).slice(0, 200) const eyebrow = searchParams.get('eyebrow')?.trim() || null - const eyebrowPill = searchParams.get('eyebrowStyle') === 'pill' const sentenceCase = searchParams.get('sentenceCase') !== '0' const manualBreaks = searchParams.get('manual') === '1' || /\n/.test(rawHeadline) @@ -206,7 +152,7 @@ export async function GET(req: Request) { const eyebrowLetterSpacing = EYEBROW.letterSpacing * eyebrowSize const eyebrowGap = 16 * s - const fonts = await satoriFonts([...new Set([EYEBROW.weight, HEADLINE.weight])]) + const fonts = await satoriFonts([...new Set([HEADLINE.weight])]) const textBlock = (
{eyebrow} @@ -284,37 +226,6 @@ export async function GET(req: Request) { /> ) : null - const cfg = resolvePattern(template.defaultPattern) - // Grid-snap (§4): phase the pattern so a grid line lands on the safe-area - // inset, anchoring the composition's left/top edges to the background grid. - const gridUnit = PATTERN_SCALE_PX[cfg.scale] * s - - // padY / H-padY mark the invisible LAYOUT BOX edge, but CSS line-height - // leading means the visible glyph ink sits a bit inside that edge — the - // grid should snap to where the text actually LOOKS like it starts/ends, - // not the box. Derived from the real Manrope metrics (already loaded for - // the auto-fit measurement above), not a guessed pixel constant, so it - // stays correct across every font size auto-fit picks. - const unitsPerEm = headlineFont.unitsPerEm - const ascentPx = (headlineFont.ascent / unitsPerEm) * headlineSize - const descentPx = (Math.abs(headlineFont.descent) / unitsPerEm) * headlineSize - const capHeightPx = (headlineFont.capHeight / unitsPerEm) * headlineSize - const halfLeading = (headlineLineHeight - (ascentPx + descentPx)) / 2 - const visualTopInset = halfLeading + ascentPx - capHeightPx // box top -> cap-height top - const visualBottomInset = halfLeading + descentPx // box bottom -> baseline - - // Grid-snap on BOTH axes: align a grid line to where THIS template's content - // actually sits (left/center × top/center/bottom), not just the top-left. - const anchorPxX = template.anchorX === 'center' ? W / 2 : padX - const anchorPxY = - template.anchorY === 'center' - ? H / 2 - : template.anchorY === 'bottom' - ? H - padY - visualBottomInset - : padY + visualTopInset - const patternOffX = ((anchorPxX % gridUnit) + gridUnit) % gridUnit - const patternOffY = ((anchorPxY % gridUnit) + gridUnit) % gridUnit - const root = template.build({ W, H, @@ -324,7 +235,6 @@ export async function GET(req: Request) { scaleFactor: s, textBlock, iconEl, - patternLayer: patternLayer(cfg, patternOffX, patternOffY), hasIcon, bgImage: hasIcon ? null : randomBackgroundDataUri(), }) @@ -342,7 +252,6 @@ export async function GET(req: Request) { 'x-og-mode': fit.mode, 'x-og-widest-line-px': String(fit.widestLinePx), 'x-og-template': template.id, - 'x-og-pattern': cfg.type, }, }) } catch (err) { diff --git a/apps/og-generator/app/page.tsx b/apps/og-generator/app/page.tsx index c24baa97248..245d0b7a07d 100644 --- a/apps/og-generator/app/page.tsx +++ b/apps/og-generator/app/page.tsx @@ -24,17 +24,12 @@ function safeAreaInset(width: number, height: number) { } type View = 'og' | 'thumb' | 'both' -type EyebrowStyle = 'text' | 'pill' const VIEW_OPTS: { value: View; label: string }[] = [ { value: 'og', label: 'OG' }, { value: 'thumb', label: 'Thumb' }, { value: 'both', label: 'Both' }, ] -const EYEBROW_STYLE_OPTS: { value: EyebrowStyle; label: string }[] = [ - { value: 'text', label: 'Plain' }, - { value: 'pill', label: 'Pill' }, -] interface FitInfo { fontSize: number @@ -67,10 +62,6 @@ function Group({ title, children }: { title: string; children: React.ReactNode } ) } -function Label({ children }: { children: React.ReactNode }) { - return {children} -} - function Segmented({ value, options, @@ -255,7 +246,7 @@ function PreviewCard({ {showSafeArea && (
('both') const [headline, setHeadline] = useState('Postgres full text search just got faster') const [eyebrow, setEyebrow] = useState('Engineering') - const [eyebrowStyle, setEyebrowStyle] = useState('pill') const [template, setTemplate] = useState(DEFAULT_TEMPLATE_ID) const [icon, setIcon] = useState(null) const [iconPickerOpen, setIconPickerOpen] = useState(false) @@ -392,14 +382,14 @@ export default function Page() { p.set('headline', headline) if (eyebrow.trim()) { p.set('eyebrow', eyebrow.trim()) - if (eyebrowStyle === 'pill') p.set('eyebrowStyle', 'pill') + p.set('eyebrowStyle', 'pill') } p.set('template', template) if (icon) p.set('icon', icon) if (scale === 2) p.set('scale', '2') return `/api/og?${p.toString()}` // eslint-disable-next-line react-hooks/exhaustive-deps - }, [brandId, formatId, headline, eyebrow, eyebrowStyle, template, icon, scale]) + }, [brandId, formatId, headline, eyebrow, template, icon, scale]) const thumbEndpoint = useMemo(() => { const p = new URLSearchParams() @@ -557,18 +547,24 @@ export default function Page() {
- {/* Floating guides / view-in-context toolbar — bottom-aligned, centered. */} -
+ {/* Floating guides / view-in-context toolbar — bottom-aligned, centered + on the same content box as the View toggle above. An absolutely + positioned child ignores its ancestor's padding, so we repeat + main's p-8/pr-[380px] insets here to land on the same center line. */} +
- + {showOg && ( <>
@@ -618,10 +614,6 @@ export default function Page() { {showOg && (
- - Template - -
{TEMPLATES.map((t) => (
)} diff --git a/apps/og-generator/lib/design/templates.tsx b/apps/og-generator/lib/design/templates.tsx index ffd7d934d0d..2e04bf1a1ea 100644 --- a/apps/og-generator/lib/design/templates.tsx +++ b/apps/og-generator/lib/design/templates.tsx @@ -2,7 +2,6 @@ import type { CSSProperties, ReactElement, ReactNode } from 'react' import type { Format } from '@/lib/design/formats' import { fullHeadlineBoxWidth } from '@/lib/design/formats' -import type { PatternColor, PatternScale, PatternType } from '@/lib/design/patterns' /** * Multi-template registry (brief §5.6). Each template is a guardrailed layout @@ -10,18 +9,11 @@ import type { PatternColor, PatternScale, PatternType } from '@/lib/design/patte * and customizes copy/icon within it, rather than building from a blank canvas. * * Templates are data: id, the headline text-box width the auto-fit measures - * against, intrinsic text alignment, a default background pattern (§6.7), and a - * `build` that arranges the pre-rendered pieces into the satori root. This shape - * maps onto the future `templates` table's og_schema_json (§8). + * against, intrinsic text alignment, and a `build` that arranges the + * pre-rendered pieces into the satori root. This shape maps onto the future + * `templates` table's og_schema_json (§8). */ -export interface TemplateDefaultPattern { - type: PatternType | 'none' - scale: PatternScale - color: PatternColor - opacity: number -} - export interface TemplateParts { W: number H: number @@ -31,7 +23,6 @@ export interface TemplateParts { scaleFactor: number textBlock: ReactNode iconEl: ReactNode | null - patternLayer: ReactNode | null hasIcon: boolean /** Fallback art (data URI) for icon-less compositions — full-bleed, behind everything else. */ bgImage?: string | null @@ -43,10 +34,9 @@ export interface Template { /** Headline text-box width (1x px) the auto-fit measures against, for a given format. */ headlineBox: (format: Format) => number textAlign: 'left' | 'center' - /** Where the content sits — used to grid-snap the background to it (§4). */ + /** Where the content sits (§4). */ anchorX: 'left' | 'center' anchorY: 'top' | 'center' | 'bottom' - defaultPattern: TemplateDefaultPattern build: (p: TemplateParts) => ReactElement } @@ -76,7 +66,6 @@ export const TEMPLATES: Template[] = [ textAlign: 'left', anchorX: 'left', anchorY: 'bottom', - defaultPattern: { type: 'dots', scale: 'md', color: 'white', opacity: 0.2 }, build: (p) => (
- {p.patternLayer} {p.hasIcon ? (
{p.iconEl} @@ -104,7 +92,6 @@ export const TEMPLATES: Template[] = [ textAlign: 'left', anchorX: 'left', anchorY: 'center', - defaultPattern: { type: 'grid', scale: 'md', color: 'white', opacity: 0.2 }, build: (p) => (
- {p.patternLayer} {p.textBlock} {p.iconEl}
@@ -130,7 +116,6 @@ export const TEMPLATES: Template[] = [ textAlign: 'center', anchorX: 'center', anchorY: 'center', - defaultPattern: { type: 'dots', scale: 'lg', color: 'green', opacity: 0.2 }, build: (p) => (
- {p.patternLayer} {p.hasIcon ? (
{p.iconEl}
) : null} @@ -156,7 +140,6 @@ export const TEMPLATES: Template[] = [ textAlign: 'left', anchorX: 'left', anchorY: 'top', - defaultPattern: { type: 'hlines', scale: 'md', color: 'white', opacity: 0.2 }, build: (p) => (
- {p.patternLayer} {p.textBlock} {p.iconEl}