diff --git a/app/api/brand/shape/route.ts b/app/api/brand/shape/route.ts
new file mode 100644
index 0000000..d3c65b4
--- /dev/null
+++ b/app/api/brand/shape/route.ts
@@ -0,0 +1,34 @@
+import { NextRequest, NextResponse } from 'next/server'
+
+import { tryLoadShape } from '@/lib/brand/shape-svg'
+import type { ShapeKind } from '@/lib/brand/types'
+
+export const runtime = 'nodejs'
+
+function isShapeKind(value: string | null): value is ShapeKind {
+ return value === 'state' || value === 'country'
+}
+
+export async function GET(request: NextRequest) {
+ const kind = request.nextUrl.searchParams.get('kind')
+ const code = request.nextUrl.searchParams.get('code')
+
+ if (!isShapeKind(kind) || !code) {
+ return NextResponse.json({ error: 'kind and code are required' }, { status: 400 })
+ }
+
+ try {
+ const shape = tryLoadShape(kind, code)
+ if (!shape) {
+ return NextResponse.json({ error: 'Shape not found' }, { status: 404 })
+ }
+ return NextResponse.json(shape, {
+ headers: {
+ 'Cache-Control': 'public, max-age=31536000, immutable',
+ },
+ })
+ } catch (error) {
+ console.error('Failed to load brand shape', error)
+ return NextResponse.json({ error: 'Shape not found' }, { status: 404 })
+ }
+}
diff --git a/app/apple-icon.tsx b/app/apple-icon.tsx
new file mode 100644
index 0000000..fa65411
--- /dev/null
+++ b/app/apple-icon.tsx
@@ -0,0 +1,40 @@
+import { ImageResponse } from 'next/og'
+import { headers } from 'next/headers'
+
+import { resolveRequestGeo } from '@/lib/brand/geo'
+import { logoDataUri } from '@/lib/brand/logo-markup'
+import { loadShape } from '@/lib/brand/shape-svg'
+import { BRAND_INK, BRAND_PAPER } from '@/lib/brand/types'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+export const size = { width: 180, height: 180 }
+export const contentType = 'image/png'
+
+export default function AppleIcon() {
+ const geo = resolveRequestGeo(headers())
+ const shape = loadShape(geo.kind, geo.code)
+ const src = logoDataUri(shape, BRAND_INK)
+
+ return new ImageResponse(
+ (
+
+

+
+ ),
+ {
+ ...size,
+ headers: {
+ 'Cache-Control': 'private, max-age=3600',
+ },
+ }
+ )
+}
diff --git a/app/icon.tsx b/app/icon.tsx
new file mode 100644
index 0000000..7b723e7
--- /dev/null
+++ b/app/icon.tsx
@@ -0,0 +1,40 @@
+import { ImageResponse } from 'next/og'
+import { headers } from 'next/headers'
+
+import { resolveRequestGeo } from '@/lib/brand/geo'
+import { logoDataUri } from '@/lib/brand/logo-markup'
+import { loadShape } from '@/lib/brand/shape-svg'
+import { BRAND_INK, BRAND_PAPER } from '@/lib/brand/types'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+export const size = { width: 32, height: 32 }
+export const contentType = 'image/png'
+
+export default function Icon() {
+ const geo = resolveRequestGeo(headers())
+ const shape = loadShape(geo.kind, geo.code)
+ const src = logoDataUri(shape, BRAND_INK)
+
+ return new ImageResponse(
+ (
+
+

+
+ ),
+ {
+ ...size,
+ headers: {
+ 'Cache-Control': 'private, max-age=3600',
+ },
+ }
+ )
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 252de2f..a971f92 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,5 +1,6 @@
import type React from "react"
import type { Metadata } from "next"
+import { headers } from "next/headers"
import { GeistSans } from "geist/font/sans"
import { GeistMono } from "geist/font/mono"
import {
@@ -15,6 +16,9 @@ import {
Poppins,
Source_Sans_3 as Source_Sans_Pro,
} from "next/font/google"
+import { GeoProvider } from "@/components/brand/geo-provider"
+import { resolveRequestGeo } from "@/lib/brand/geo"
+import { loadShape } from "@/lib/brand/shape-svg"
import "./globals.css"
// Load Geist fonts for UI (already configured font objects)
@@ -45,12 +49,15 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode
}>) {
+ const geo = resolveRequestGeo(headers())
+ const initialShape = loadShape(geo.kind, geo.code)
+
return (
- {children}
+ {children}
)
diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx
new file mode 100644
index 0000000..58ea960
--- /dev/null
+++ b/app/opengraph-image.tsx
@@ -0,0 +1,94 @@
+import { readFile } from 'fs/promises'
+import { join } from 'path'
+
+import { ImageResponse } from 'next/og'
+
+import { logoDataUri } from '@/lib/brand/logo-markup'
+import { loadShape } from '@/lib/brand/shape-svg'
+import { BRAND_INK, BRAND_MUTED, BRAND_PAPER, FALLBACK_SHAPE_ID } from '@/lib/brand/types'
+
+export const runtime = 'nodejs'
+export const alt = 'Map Studio — Create data-rich maps without leaving your browser. By DFLabs.'
+export const size = { width: 1200, height: 630 }
+export const contentType = 'image/png'
+
+async function loadFont(relativePath: string) {
+ return readFile(join(process.cwd(), 'node_modules/geist/dist/fonts', relativePath))
+}
+
+export default async function OpenGraphImage() {
+ const shape = loadShape(FALLBACK_SHAPE_ID.kind, FALLBACK_SHAPE_ID.code)
+ const src = logoDataUri(shape, BRAND_INK)
+ const [geistMonoBold, geistSansRegular, geistSansMedium] = await Promise.all([
+ loadFont('geist-mono/GeistMono-Bold.ttf'),
+ loadFont('geist-sans/Geist-Regular.ttf'),
+ loadFont('geist-sans/Geist-Medium.ttf'),
+ ])
+
+ return new ImageResponse(
+ (
+
+
+

+
+
+
+ Map Studio
+
+
+
Create data-rich maps
+
without leaving your browser.
+
+
+ By DFLabs.
+
+
+
+ ),
+ {
+ ...size,
+ fonts: [
+ { name: 'Geist Mono', data: geistMonoBold, weight: 700, style: 'normal' },
+ { name: 'Geist Sans', data: geistSansRegular, weight: 400, style: 'normal' },
+ { name: 'Geist Sans', data: geistSansMedium, weight: 500, style: 'normal' },
+ ],
+ }
+ )
+}
diff --git a/app/twitter-image.tsx b/app/twitter-image.tsx
new file mode 100644
index 0000000..7e3f8b3
--- /dev/null
+++ b/app/twitter-image.tsx
@@ -0,0 +1 @@
+export { default, alt, size, contentType, runtime } from './opengraph-image'
diff --git a/components/brand/animated-logo.tsx b/components/brand/animated-logo.tsx
new file mode 100644
index 0000000..c02493f
--- /dev/null
+++ b/components/brand/animated-logo.tsx
@@ -0,0 +1,144 @@
+'use client'
+
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+import { useGeoShape } from '@/components/brand/geo-provider'
+import { pickRandomShape } from '@/lib/brand/cycle-pool'
+import { LogoFrame, LogoShapeGroup } from '@/lib/brand/logo-svg'
+import { LOGO_FRAME, LOGO_PAD } from '@/lib/brand/types'
+import type { ParsedShape, ShapeId } from '@/lib/brand/types'
+
+const shapeCache = new Map()
+
+function cacheKey(id: ShapeId): string {
+ return `${id.kind}:${id.code}`
+}
+
+async function fetchShape(id: ShapeId): Promise {
+ const key = cacheKey(id)
+ const cached = shapeCache.get(key)
+ if (cached) return cached
+
+ const response = await fetch(`/api/brand/shape?kind=${id.kind}&code=${encodeURIComponent(id.code)}`)
+ if (!response.ok) {
+ throw new Error(`Failed to load shape ${key}`)
+ }
+ const shape = (await response.json()) as ParsedShape
+ shapeCache.set(key, shape)
+ return shape
+}
+
+function usePrefersReducedMotion(): boolean {
+ const [reduced, setReduced] = useState(false)
+
+ useEffect(() => {
+ const media = window.matchMedia('(prefers-reduced-motion: reduce)')
+ setReduced(media.matches)
+ const onChange = () => setReduced(media.matches)
+ media.addEventListener('change', onChange)
+ return () => media.removeEventListener('change', onChange)
+ }, [])
+
+ return reduced
+}
+
+export function AnimatedLogo({ intervalMs, size = 32 }: { intervalMs: number; size?: number }) {
+ const initialShape = useGeoShape()
+ const reducedMotion = usePrefersReducedMotion()
+ const [front, setFront] = useState(initialShape)
+ const [back, setBack] = useState(null)
+ const [frontVisible, setFrontVisible] = useState(true)
+ const frontRef = useRef(front)
+ const backRef = useRef(back)
+ const frontVisibleRef = useRef(frontVisible)
+ const nextRef = useRef(null)
+ const inflight = useRef(false)
+
+ frontRef.current = front
+ backRef.current = back
+ frontVisibleRef.current = frontVisible
+
+ useEffect(() => {
+ if (!initialShape) return
+ shapeCache.set(cacheKey(initialShape), initialShape)
+ setFront((current) => current ?? initialShape)
+ }, [initialShape])
+
+ const prepareNext = useCallback(async (current: ParsedShape) => {
+ if (nextRef.current) return nextRef.current
+ const loaded = await fetchShape(pickRandomShape(current))
+ nextRef.current = loaded
+ return loaded
+ }, [])
+
+ useEffect(() => {
+ if (reducedMotion || !front) return
+ void prepareNext(front).catch((error) => console.error(error))
+ }, [front, prepareNext, reducedMotion])
+
+ const cycle = useCallback(async () => {
+ if (inflight.current) return
+ const current = frontVisibleRef.current ? frontRef.current : backRef.current
+ if (!current) return
+
+ inflight.current = true
+ try {
+ const nextShape = await prepareNext(current)
+ nextRef.current = null
+ if (frontVisibleRef.current) {
+ setBack(nextShape)
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => setFrontVisible(false))
+ })
+ } else {
+ setFront(nextShape)
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => setFrontVisible(true))
+ })
+ }
+ void prepareNext(nextShape).catch((error) => console.error(error))
+ } catch (error) {
+ console.error(error)
+ } finally {
+ inflight.current = false
+ }
+ }, [prepareNext])
+
+ useEffect(() => {
+ if (reducedMotion || intervalMs <= 0) return
+ const timer = window.setInterval(() => {
+ void cycle()
+ }, intervalMs)
+ return () => window.clearInterval(timer)
+ }, [cycle, intervalMs, reducedMotion])
+
+ return (
+
+ )
+}
diff --git a/components/brand/brand-lockup.tsx b/components/brand/brand-lockup.tsx
new file mode 100644
index 0000000..c8e8e8a
--- /dev/null
+++ b/components/brand/brand-lockup.tsx
@@ -0,0 +1,50 @@
+'use client'
+
+import Link from 'next/link'
+import type { ReactNode } from 'react'
+
+import { AnimatedLogo } from '@/components/brand/animated-logo'
+
+const wordmarkClass =
+ 'font-mono text-[10px] font-bold uppercase tracking-[0.22em] text-foreground'
+
+export function BrandDivider() {
+ return (
+
+ )
+}
+
+export function BrandLockup({
+ intervalMs,
+ showWordmark,
+ afterDivider,
+}: {
+ intervalMs: number
+ showWordmark?: boolean
+ afterDivider?: ReactNode
+}) {
+ const mark =
+
+ if (showWordmark) {
+ return (
+
+ {mark}
+
+ Map Studio
+
+ )
+ }
+
+ return (
+
+
+ {mark}
+
+
+ {afterDivider}
+
+ )
+}
diff --git a/components/brand/geo-provider.tsx b/components/brand/geo-provider.tsx
new file mode 100644
index 0000000..af37a34
--- /dev/null
+++ b/components/brand/geo-provider.tsx
@@ -0,0 +1,21 @@
+'use client'
+
+import { createContext, useContext, type ReactNode } from 'react'
+
+import type { ParsedShape } from '@/lib/brand/types'
+
+const GeoContext = createContext(null)
+
+export function GeoProvider({
+ initialShape,
+ children,
+}: {
+ initialShape: ParsedShape
+ children: ReactNode
+}) {
+ return {children}
+}
+
+export function useGeoShape(): ParsedShape | null {
+ return useContext(GeoContext)
+}
diff --git a/components/brand/logo-frame.tsx b/components/brand/logo-frame.tsx
new file mode 100644
index 0000000..662f868
--- /dev/null
+++ b/components/brand/logo-frame.tsx
@@ -0,0 +1 @@
+export { LogoFrame } from '@/lib/brand/logo-svg'
diff --git a/components/brand/logo-mark.tsx b/components/brand/logo-mark.tsx
new file mode 100644
index 0000000..c58de4c
--- /dev/null
+++ b/components/brand/logo-mark.tsx
@@ -0,0 +1 @@
+export { LogoMarkSvg as LogoMark } from '@/lib/brand/logo-svg'
diff --git a/components/header.tsx b/components/header.tsx
index 1b87b60..af04eb6 100644
--- a/components/header.tsx
+++ b/components/header.tsx
@@ -1,6 +1,5 @@
'use client'
-import Link from 'next/link'
import { Save, Upload, Loader2 } from 'lucide-react'
import { ThemeToggle } from '@/components/theme-toggle'
import { Button } from '@/components/ui/button'
@@ -11,6 +10,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { StudioModeTabs } from '@/components/studio-mode-tabs'
+import { BrandLockup } from '@/components/brand/brand-lockup'
import { useStudioChrome } from '@/lib/studio-chrome-context'
import { studioPrimaryButtonClass } from '@/components/studio-panel'
import { cn } from '@/lib/utils'
@@ -23,27 +23,25 @@ export function Header() {
return (