From 2e27ff8a9576fd600cd2e360f02c8e9049aba6d3 Mon Sep 17 00:00:00 2001 From: Shane Date: Tue, 7 Jul 2026 20:29:21 -0500 Subject: [PATCH] Toolbar/layout polish: icon-button safe area, drop template patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Safe area toggle in the bottom toolbar is now a dotted-box icon button instead of a checkbox + label; the on-image guide is dotted to match - Bottom toolbar now aligns to the same center line as the View toggle above it (both toolbars sit in main's padded content box, but absolute positioning ignores ancestor padding, so the offsets are repeated) - Eyebrow style selector removed — always renders as a pill now - Reduced the eyebrow pill's font weight one step (500 -> 400) - Removed each template's default background pattern (dots/grid/hlines/ vlines) entirely, along with the now-dead grid-snap alignment math; icon-less compositions still get the random image background - Removed the "Template" label above the layout tiles --- apps/og-generator/app/api/og/route.tsx | 111 ++------------------- apps/og-generator/app/page.tsx | 52 ++++------ apps/og-generator/lib/design/templates.tsx | 26 +---- 3 files changed, 34 insertions(+), 155 deletions(-) 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}