// Postcard — a 2-sided virtual postcard the user can send to a friend. // Front: a photo (landscape or portrait). Back: message + stamp + postmark. // Tap card to flip; pill below the card is the non-gestural fallback. // // Visual system is "midday" (warm white / deep ink / ultramarine / tomato). const PC_INK = "#1a1814"; const PC_INK_SOFT = "#5b554a"; const PC_CREAM = "#f5f2e8"; const PC_PAPER = "#fbf9f0"; const PC_ULTRA = "#2240ff"; const PC_TOMATO = "#ff4430"; const PC_SERIF = '"Source Serif 4", "Source Serif Pro", Georgia, serif'; const PC_MONO = 'ui-monospace, "SF Mono", Menlo, monospace'; // PhotoPlaceholder — striped fallback when no real photo is attached. // Two tones so landscape vs portrait reads as distinct artwork. function PhotoPlaceholder({ caption, tone = "warm" }) { const palette = tone === "cool" ? { a: "#3a4a6b", b: "#2c3a55", text: "rgba(245,242,232,0.78)" } : { a: "#c98a4b", b: "#b6743a", text: "rgba(26,24,20,0.62)" }; return (
{caption && (
{caption}
)}
); } // Stamp — typographic city stamp with a perforated edge and a 4×4 azulejo // tile motif. Country word top, city + value bottom; value is the only // place tomato appears here. function Stamp({ scale = 1, city = "LISBOA", country = "PORTUGAL", value = "€1,20", }) { const W = 96 * scale; const H = 116 * scale; const perfSize = 6 * scale; const perfBg = `radial-gradient(circle at center, ${PC_CREAM} ${perfSize / 2}px, transparent ${perfSize / 2 + 0.5}px)`; return (
{country}
{Array.from({ length: 16 }).map((_, i) => { const r = Math.floor(i / 4), c = i % 4; const fill = (r + c) % 2 === 0 ? PC_ULTRA : "transparent"; const ring = (r + c) % 2 === 0 ? "transparent" : PC_ULTRA; return (
); })}
{city}
{value}
); } // Postmark — concentric rings + arc-set city/agency + center date. // Rendered in tomato ink with multiply blend so it sits on the stamp. function Postmark({ size = 84, city = "LISBOA", agency = "CORREIOS", date = "17 · APR · 26", rotate = -8, }) { // Unique ids per instance so two Postmarks on the same page don't collide. const uid = React.useId(); const topId = `pm-arc-top-${uid}`; const botId = `pm-arc-bot-${uid}`; return (
{city} {agency} {date}
); } // CardSurface — the physical card chrome: rounded corners, layered shadow, // warm paper background. Wraps a face. function CardSurface({ width, height, children, padding = 0, bg = PC_PAPER }) { return (
{children}
); } // Front face — the photo fills edge-to-edge regardless of orientation. // `photoUrl` overrides the placeholder when present. function PostcardFront({ photoUrl, caption, tone = "warm" }) { if (photoUrl) { return (
); } return (
); } // Back face — landscape: message on the left, address + stamp + postmark on // the right, classic center rule splitting them. function PostcardBackLandscape({ width, height, date = "17 April · Lisboa", message = [ "Mara — sat at a tile bench above the river", "all afternoon. The trams keep going by but", "I don't get on any of them. Saving the pastel", "de nata place for when you're here.", "— J", ], to = { name: "Mara Halloran", lines: ["14 Greenwood Place", "Brooklyn NY 11215"] }, stamp, postmark, }) { const lineColor = "rgba(26,24,20,0.10)"; const lineGap = 26; const messageText = Array.isArray(message) ? message.join("\n") : (message || ""); return (
{date}
{messageText}
To
{to.name}
{(to.lines || []).map((l, i) => (
{l}
))}
); } // Back face — portrait: header strip with date / address / stamp on top, // then ruled message lines below. function PostcardBackPortrait({ width, height, date = "17 April · Lisboa", message = [ "Mara — sat at a tile bench above", "the river all afternoon. The trams", "keep going by but I don't get on", "any of them. Saving the pastel de", "nata place for when you're here.", "— J", ], to = { name: "Mara Halloran", lines: ["14 Greenwood Pl · Brooklyn NY"] }, stamp, postmark, }) { const lineColor = "rgba(26,24,20,0.10)"; const lineGap = 24; return (
{date}
To
{to.name}
{(to.lines || []).map((l, i) => (
{l}
))}
{Array.isArray(message) ? message.join("\n") : (message || "")}
); } // FlipGlyph — two overlapping rectangles, signals "two faces of one object". function FlipGlyph() { return ( ); } // FlippableCard — the interactive card. `orientation: "landscape" | "portrait"` // drives the card's frame; the photo fills it either way. Tap to flip; the // pill below the card is the fallback control. function FlippableCard({ width = 520, height = 340, orientation = "landscape", initialSide = "front", photoUrl, photoCaption, photoTone, date, message, to, stamp, postmark, }) { const [side, setSide] = React.useState(initialSide); const flipped = side === "back"; const isPortrait = orientation === "portrait"; // Postcards are landscape objects by default; portrait swaps the frame. const cardW = isPortrait ? Math.round(height * 0.78) : width; const cardH = height; const tone = photoTone || (isPortrait ? "cool" : "warm"); const caption = photoCaption || (isPortrait ? "Photo · portrait · 3:4" : "Photo · landscape · 4:3"); const Back = isPortrait ? PostcardBackPortrait : PostcardBackLandscape; const onToggle = () => setSide(flipped ? "front" : "back"); return (
); } // StaticCard — both faces, stacked, no flip animation. Used for the public // viewer where we want the layout to look identical across devices: fixed // pixel dimensions, no responsive scaling. The page-level zoom is disabled // at the viewport meta; the CardZoom wrapper handles pinch + wheel zoom. function StaticCard({ width = 720, height = 480, orientation = "landscape", photoUrl, photoCaption, photoTone, date, message, to, stamp, postmark, }) { const isPortrait = orientation === "portrait"; const cardW = isPortrait ? Math.round(height * 0.78) : width; const cardH = height; const tone = photoTone || (isPortrait ? "cool" : "warm"); const caption = photoCaption || (isPortrait ? "Photo · portrait · 3:4" : "Photo · landscape · 4:3"); const Back = isPortrait ? PostcardBackPortrait : PostcardBackLandscape; return (
); } // CardZoom — pinch-zoom (touch), ctrl/cmd + wheel zoom (desktop), pan when // zoomed, double-tap to reset. The page itself has zoom disabled via // viewport meta — this is the only zoomable surface. function CardZoom({ children, maxScale = 4 }) { const wrapperRef = React.useRef(null); const stateRef = React.useRef({ scale: 1, tx: 0, ty: 0 }); const gestureRef = React.useRef(null); React.useEffect(() => { const el = wrapperRef.current; if (!el) return; const apply = () => { const { scale, tx, ty } = stateRef.current; el.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`; }; const settle = () => { // If we slipped just below 1, snap back. Animated. if (stateRef.current.scale <= 1.02) { stateRef.current = { scale: 1, tx: 0, ty: 0 }; el.style.transition = "transform 220ms ease"; apply(); setTimeout(() => { el.style.transition = ""; }, 240); } }; const onTouchStart = (e) => { if (e.touches.length === 2) { e.preventDefault(); const [a, b] = e.touches; gestureRef.current = { type: "pinch", dist: Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY), startScale: stateRef.current.scale, startTx: stateRef.current.tx, startTy: stateRef.current.ty, }; } else if (e.touches.length === 1 && stateRef.current.scale > 1) { gestureRef.current = { type: "pan", x: e.touches[0].clientX, y: e.touches[0].clientY, startTx: stateRef.current.tx, startTy: stateRef.current.ty, }; } }; const onTouchMove = (e) => { const g = gestureRef.current; if (!g) return; if (g.type === "pinch" && e.touches.length === 2) { e.preventDefault(); const [a, b] = e.touches; const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); const ratio = dist / g.dist; stateRef.current.scale = Math.max(1, Math.min(maxScale, g.startScale * ratio)); stateRef.current.tx = g.startTx; stateRef.current.ty = g.startTy; apply(); } else if (g.type === "pan" && e.touches.length === 1) { e.preventDefault(); const dx = e.touches[0].clientX - g.x; const dy = e.touches[0].clientY - g.y; stateRef.current.tx = g.startTx + dx; stateRef.current.ty = g.startTy + dy; apply(); } }; const onTouchEnd = () => { gestureRef.current = null; settle(); }; let lastTap = 0; const onTouchTap = (e) => { // Double-tap to reset (only on touch — desktop uses dblclick). if (e.changedTouches && e.changedTouches.length === 1) { const now = Date.now(); if (now - lastTap < 320) { stateRef.current = { scale: 1, tx: 0, ty: 0 }; el.style.transition = "transform 220ms ease"; apply(); setTimeout(() => { el.style.transition = ""; }, 240); } lastTap = now; } }; const onDoubleClick = (e) => { e.preventDefault(); stateRef.current = { scale: 1, tx: 0, ty: 0 }; el.style.transition = "transform 220ms ease"; apply(); setTimeout(() => { el.style.transition = ""; }, 240); }; const onWheel = (e) => { // ctrl/cmd + wheel zooms; bare wheel scrolls the page. if (!e.ctrlKey && !e.metaKey) return; e.preventDefault(); const newScale = Math.max(1, Math.min(maxScale, stateRef.current.scale - e.deltaY * 0.01)); stateRef.current.scale = newScale; apply(); settle(); }; el.addEventListener("touchstart", onTouchStart, { passive: false }); el.addEventListener("touchmove", onTouchMove, { passive: false }); el.addEventListener("touchend", onTouchEnd); el.addEventListener("touchend", onTouchTap); el.addEventListener("dblclick", onDoubleClick); el.addEventListener("wheel", onWheel, { passive: false }); return () => { el.removeEventListener("touchstart", onTouchStart); el.removeEventListener("touchmove", onTouchMove); el.removeEventListener("touchend", onTouchEnd); el.removeEventListener("touchend", onTouchTap); el.removeEventListener("dblclick", onDoubleClick); el.removeEventListener("wheel", onWheel); }; }, [maxScale]); return (
{children}
); } Object.assign(window, { FlippableCard, StaticCard, CardZoom, CardSurface, Stamp, Postmark, FlipGlyph, PostcardFront, PostcardBackLandscape, PostcardBackPortrait, PhotoPlaceholder, PC_INK, PC_INK_SOFT, PC_CREAM, PC_PAPER, PC_ULTRA, PC_TOMATO, PC_SERIF, PC_MONO, });