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. */}
+
-
- setShowSafeArea(e.target.checked)}
- />
- Safe area
-
+
setShowSafeArea((v) => !v)}
+ title="Show safe-area guide"
+ className={`flex h-7 w-7 items-center justify-center rounded ${
+ showSafeArea ? 'bg-surface-300 text-foreground' : 'text-foreground-light hover:text-foreground'
+ }`}
+ >
+
+
+
+
{showOg && (
<>
@@ -618,10 +614,6 @@ export default function Page() {
{showOg && (
-
- Template
-
-
{TEMPLATES.map((t) => (
-
- Style
-
-
)}
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}