Toolbar/layout polish: icon-button safe area, drop template patterns

- 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
This commit is contained in:
Shane committed 2026-07-07 20:29:21 -05:00
1 parent a8304396ae
commit 2e27ff8a95
3 files changed
+34 -155

No files matched your search

+10 -101
View File
@@ -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 <img width={W} height={H} src={uri} style={{ position: 'absolute', top: 0, left: 0 }} />
}
// ---- 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 = (
<div
@@ -221,19 +167,15 @@ export async function GET(req: Request) {
style={{
display: 'flex',
marginBottom: eyebrowGap,
color: eyebrowPill ? '#000000' : color('brand.default', brand),
color: '#000000',
fontFamily: 'IBM Plex Mono',
fontSize: eyebrowSize,
fontWeight: EYEBROW.weight,
fontWeight: EYEBROW_PILL_WEIGHT,
letterSpacing: eyebrowLetterSpacing,
textTransform: 'uppercase',
...(eyebrowPill
? {
backgroundColor: '#FFFFFF',
borderRadius: 999,
padding: `${8 * s}px ${18 * s}px`,
}
: {}),
backgroundColor: '#FFFFFF',
borderRadius: 999,
padding: `${8 * s}px ${18 * s}px`,
}}
>
{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) {
+20 -32
View File
@@ -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 <span className="text-sm text-foreground-light">{children}</span>
}
function Segmented<T extends string>({
value,
options,
@@ -255,7 +246,7 @@ function PreviewCard({
{showSafeArea && (
<div className="pointer-events-none absolute inset-0">
<div
className="absolute border border-dashed border-brand/40"
className="absolute border border-dotted border-brand/40"
style={{
top: `${safeArea.y}%`,
bottom: `${safeArea.y}%`,
@@ -288,7 +279,6 @@ export default function Page() {
const [view, setView] = useState<View>('both')
const [headline, setHeadline] = useState('Postgres full text search just got faster')
const [eyebrow, setEyebrow] = useState('Engineering')
const [eyebrowStyle, setEyebrowStyle] = useState<EyebrowStyle>('pill')
const [template, setTemplate] = useState(DEFAULT_TEMPLATE_ID)
const [icon, setIcon] = useState<string | null>(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() {
</div>
</div>
{/* Floating guides / view-in-context toolbar — bottom-aligned, centered. */}
<div className="pointer-events-none absolute inset-x-0 bottom-6 z-10 flex justify-center">
{/* 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. */}
<div className="pointer-events-none absolute bottom-6 left-8 right-[380px] z-10 flex justify-center">
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-default bg-background px-3 py-2 shadow-lg">
<label className="flex items-center gap-1.5 px-1 text-xs text-foreground-light">
<input
type="checkbox"
id="toggle-safe-area"
checked={showSafeArea}
onChange={(e) => setShowSafeArea(e.target.checked)}
/>
Safe area
</label>
<button
type="button"
onClick={() => 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'
}`}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="4" y="4" width="16" height="16" rx="2" strokeDasharray="0.1 4.2" strokeLinecap="round" />
</svg>
</button>
{showOg && (
<>
<div className="h-5 border-l border-default" />
@@ -618,10 +614,6 @@ export default function Page() {
{showOg && (
<Group title="Layout">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-foreground-light">
Template
<Hint text="Prebuilt layouts that already satisfy the safe area + alignment rules (§5.6). Pick one, then customize within it." />
</span>
<div className="grid grid-cols-2 gap-2">
{TEMPLATES.map((t) => (
<button
@@ -665,10 +657,6 @@ export default function Page() {
className="rounded-md border border-default bg-surface-100 px-3 py-2 text-sm text-foreground outline-none focus:border-strong"
placeholder="e.g. Launch Week, Engineering"
/>
<div className="flex items-center justify-between">
<Label>Style</Label>
<Segmented value={eyebrowStyle} onChange={setEyebrowStyle} options={EYEBROW_STYLE_OPTS} />
</div>
</div>
)}
+4 -22
View File
@@ -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) => (
<div
style={{
@@ -86,7 +75,6 @@ export const TEMPLATES: Template[] = [
alignItems: 'flex-start',
}}
>
{p.patternLayer}
{p.hasIcon ? (
<div style={{ display: 'flex', width: p.W - p.padX * 2, justifyContent: 'flex-end' }}>
{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) => (
<div
style={{
@@ -115,7 +102,6 @@ export const TEMPLATES: Template[] = [
gap: 56 * p.scaleFactor,
}}
>
{p.patternLayer}
{p.textBlock}
{p.iconEl}
</div>
@@ -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) => (
<div
style={{
@@ -141,7 +126,6 @@ export const TEMPLATES: Template[] = [
textAlign: 'center',
}}
>
{p.patternLayer}
{p.hasIcon ? (
<div style={{ display: 'flex', marginBottom: 36 * p.scaleFactor }}>{p.iconEl}</div>
) : 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) => (
<div
style={{
@@ -166,7 +149,6 @@ export const TEMPLATES: Template[] = [
alignItems: 'flex-start',
}}
>
{p.patternLayer}
{p.textBlock}
{p.iconEl}
</div>