mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 03:15:06 +03:00
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:
1 parent
a8304396ae
commit
2e27ff8a95
3 files changed
+34
-155
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user