diff --git a/site/app/globals.css b/site/app/globals.css index 0f5810b..98c4feb 100644 --- a/site/app/globals.css +++ b/site/app/globals.css @@ -41,7 +41,7 @@ body::before { content: ''; position: fixed; top: 0; left: 0; right: 0; - height: 14px; + height: 44px; background: linear-gradient(90deg, #295df6, #c6a0fd, #5cd500, #ff7a45, #ff3e8c, #00c9a7); filter: blur(33px); opacity: 0.2; diff --git a/site/components/Hero.tsx b/site/components/Hero.tsx index 6ca85d7..ecf2bb2 100644 --- a/site/components/Hero.tsx +++ b/site/components/Hero.tsx @@ -1,8 +1,56 @@ 'use client' +import { useEffect, useRef, useState } from 'react' import { StampStack } from 'stampstack' import { DEMO_ITEMS, demoColor } from './demo-stamps' +import { StampToaster, type ToastData } from './StampToast' export function Hero() { + // The stack of tapped stamps, newest last. Capped to the visible depth so the + // stack stays tidy; each toast self-dismisses (see StampToaster). + const [toasts, setToasts] = useState([]) + + // One-time intro flourish: once the hero settles, riffle the fan quickly out to + // the last stamp and back to the first, then stop. The library listens for arrow + // keys on window, so we synthesize a fast burst of ArrowRight then ArrowLeft + // keydowns — its own 0.45s card transition smooths the rapid steps into one + // continuous sweep (no library control API needed). Skipped under reduced motion; + // aborts the moment the user takes over (real keys / pointer set pausedRef). + const pausedRef = useRef(false) + + useEffect(() => { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return + + // Real (user) arrow/enter keys abort the flourish. Our synthetic keys carry + // isTrusted=false, so they slip past this guard. + const onUserKey = (e: KeyboardEvent) => { + if (e.isTrusted && (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter')) { + pausedRef.current = true + } + } + window.addEventListener('keydown', onUserKey) + + const last = DEMO_ITEMS.length - 1 + const STEP_MS = 70 // gap per step — far under the 0.45s card transition, so it flows + const START_MS = 700 // let the hero's entrance rise finish before the sweep + + // Out to the last stamp, then back to the first — a single rapid pass. + const keys: ('ArrowRight' | 'ArrowLeft')[] = [ + ...Array(last).fill('ArrowRight'), + ...Array(last).fill('ArrowLeft'), + ] + const timers = keys.map((key, i) => + window.setTimeout(() => { + if (pausedRef.current || document.hidden) return + window.dispatchEvent(new KeyboardEvent('keydown', { key })) + }, START_MS + i * STEP_MS), + ) + + return () => { + timers.forEach((t) => window.clearTimeout(t)) + window.removeEventListener('keydown', onUserKey) + } + }, []) + return (
@@ -26,7 +74,7 @@ export function Hero() { className="rise" style={{ display: 'flex', alignItems: 'center', alignSelf: 'stretch', whiteSpace: 'nowrap', marginTop: -10, animationDelay: '160ms' }} > - + stampstack v0.2.0 @@ -45,9 +93,10 @@ export function Hero() { className="rise" style={{ margin: 0, maxWidth: 486, color: '#252525', fontWeight: 500, fontSize: 14, letterSpacing: '-0.1px', lineHeight: '23px', fontFeatureSettings: '"swsh" 1', animationDelay: '200ms' }} > - stampstack is a postage-styled 3D carousel component. Install and drop in whatever content you want on the stamps. - no dependencies beyond React 18. + stampstack is a postage-styled 3D carousel component. install and drop in whatever content you want on the stamps. no deps beyond react 18 +

+ @@ -57,6 +106,7 @@ export function Hero() { then spreads symmetrically and clips cleanly at both edges. */}
{ pausedRef.current = true }} style={{ position: 'relative', overflow: 'visible', @@ -69,7 +119,6 @@ export function Hero() { cardWidth={240} style={{ width: 240, height: 340 }} frameColor={(item) => demoColor(item.id)} - onSelect={(item) => window.alert(`Tapped ${item.label}`)} renderStamp={(item, state) => (
- {item.label} + {item.title}
)} />
+ + {/* Tap a stamp → a Sonner-style toast joins the stack (swatch + title + + subtitle). Newest sits in front; hover expands, swipe-down dismisses. */} + setToasts((t) => t.filter((x) => x.key !== key))} + />
) } diff --git a/site/components/InstallTabs.tsx b/site/components/InstallTabs.tsx index 55ca9d0..a0c8dc5 100644 --- a/site/components/InstallTabs.tsx +++ b/site/components/InstallTabs.tsx @@ -9,11 +9,17 @@ const COMMANDS: Record = { bun: 'bun add stampstack', } -// Copy-this-to-your-agent prompt (react-grab style). -const PROMPT = `Set up stampstack in this React project. +// Copy-this-to-your-agent prompt (react-grab style). Written to handle any +// starting point — empty directory, fresh scaffold, or existing React project. +const PROMPT = `Set up stampstack — handle whichever case applies to this directory. -1. Detect the package manager from the lockfile and install stampstack (e.g. npm i stampstack). -2. Where you use it, import the component and its styles: +1. Install: + - Existing React project: install with the project's package manager. Detect it + from the lockfile (package-lock.json → npm, pnpm-lock.yaml → pnpm, yarn.lock → yarn, + bun.lockb → bun); if there's no lockfile, use npm. e.g. npm i stampstack + - Empty directory (no package.json): scaffold a React app first, then install — + npm create vite@latest . -- --template react-ts && npm install && npm i stampstack +2. Import the component and its styles where you use it: import { StampStack } from 'stampstack' import 'stampstack/styles.css' 3. Render it with your data — each item only needs an id: @@ -21,7 +27,8 @@ const PROMPT = `Set up stampstack in this React project. 4. Optional: frameColor={(item) => '#...'} for a per-stamp frame color, and onSelect={(item) => ...} to open a card on tap. -It renders real DOM (not a canvas), so any content — text, images, links — works inside renderStamp.` +Works in any React 18+ app (Vite, Next.js, CRA). It renders real DOM (not a canvas), +so any content — text, images, links — works inside renderStamp.` const TABS = ['npm', 'pnpm', 'yarn', 'bun', 'Prompt'] as const diff --git a/site/components/StampToast.tsx b/site/components/StampToast.tsx new file mode 100644 index 0000000..6ffc223 --- /dev/null +++ b/site/components/StampToast.tsx @@ -0,0 +1,225 @@ +'use client' +import { useCallback, useEffect, useRef, useState } from 'react' +import type { DemoItem } from './demo-stamps' + +// A Sonner-style stacked toaster, handrolled (no dependency) to match the +// site's zero-dep ethos. Each tap pushes a toast; the newest sits in front and +// older ones scale back + peek behind. Hovering expands the stack into a list; +// the front toast can be swiped down to dismiss; each auto-dismisses after 4s +// (paused while hovered). Motion uses Sonner's springy ease. + +export type ToastData = { key: number; item: DemoItem; color: string } + +const MAX_VISIBLE = 3 // toasts rendered in the collapsed stack +const COLLAPSE_GAP = 16 // px each older toast rises behind the front +const SCALE_STEP = 0.06 // scale lost per step back +const TOAST_H = 80 // approx uniform toast height (content is fixed) +const EXPAND_GAP = 12 // gap between toasts when the stack is expanded +const ENTER_FROM = 28 // px below resting spot a new toast slides up from +const AUTO_MS = 4000 +const EXIT_MS = 200 +const SWIPE_DISMISS = 45 // px of downward drag that commits a dismiss +const EASE = 'cubic-bezier(0.21, 1.02, 0.73, 1)' // Sonner's slight-overshoot spring + +export function StampToaster({ + toasts, + onDismiss, +}: { + toasts: ToastData[] + onDismiss: (key: number) => void +}) { + // Expanded while the pointer is over any toast. Debounced on leave so sliding + // between stacked toasts (leave A → enter B) doesn't collapse for a frame. + const [expanded, setExpanded] = useState(false) + const leaveTimer = useRef(undefined) + const onEnter = useCallback(() => { + if (leaveTimer.current) clearTimeout(leaveTimer.current) + setExpanded(true) + }, []) + const onLeave = useCallback(() => { + leaveTimer.current = window.setTimeout(() => setExpanded(false), 60) + }, []) + + return ( +
+ {toasts.map((t, i) => ( + + ))} +
+ ) +} + +function ToastItem({ + data, + pos, + expanded, + onEnter, + onLeave, + onDismiss, +}: { + data: ToastData + pos: number + expanded: boolean + onEnter: () => void + onLeave: () => void + onDismiss: (key: number) => void +}) { + const [phase, setPhase] = useState<'enter' | 'rest' | 'exit'>('enter') + const [dragging, setDragging] = useState(false) + const [dragY, setDragY] = useState(0) + const startY = useRef(0) + const isFront = pos === 0 + + // Enter on the next frame (so the from-state paints first → transition runs). + useEffect(() => { + const r = requestAnimationFrame(() => setPhase('rest')) + return () => cancelAnimationFrame(r) + }, []) + + // Animate out, then let the parent drop us from the list. + const dismiss = useCallback(() => { + setPhase('exit') + const t = setTimeout(() => onDismiss(data.key), EXIT_MS) + return () => clearTimeout(t) + }, [onDismiss, data.key]) + + // Auto-dismiss — paused while the stack is hovered (expanded) or being dragged. + useEffect(() => { + if (phase !== 'rest' || expanded || dragging) return + const t = setTimeout(dismiss, AUTO_MS) + return () => clearTimeout(t) + }, [phase, expanded, dragging, dismiss]) + + // Swipe-to-dismiss — front toast only. + const onPointerDown = (e: React.PointerEvent) => { + if (!isFront) return + startY.current = e.clientY + setDragging(true) + e.currentTarget.setPointerCapture(e.pointerId) + } + const onPointerMove = (e: React.PointerEvent) => { + if (!dragging) return + // Follow downward freely; resist upward (this stack dismisses downward). + const dy = e.clientY - startY.current + setDragY(dy > 0 ? dy : dy * 0.3) + } + const onPointerUp = () => { + if (!dragging) return + setDragging(false) + if (dragY > SWIPE_DISMISS) dismiss() + else setDragY(0) // snap back + } + + // Resting position from depth: rise + shrink (collapsed) or full list (expanded). + const baseY = expanded ? -pos * (TOAST_H + EXPAND_GAP) : -pos * COLLAPSE_GAP + const baseScale = expanded ? 1 : 1 - pos * SCALE_STEP + + let transform: string + let opacity: number + if (phase === 'enter') { + transform = `translateY(${baseY + ENTER_FROM}px) scale(0.96)` + opacity = 0 + } else if (phase === 'exit') { + transform = `translateY(${baseY + dragY + 48}px) scale(0.96)` + opacity = 0 + } else { + transform = `translateY(${baseY + dragY}px) scale(${baseScale})` + // Beyond the visible count, fade out behind the stack; fade while swiping. + const swipeFade = dragY > 0 ? Math.max(0, 1 - dragY / (TOAST_H * 1.4)) : 1 + opacity = (pos < MAX_VISIBLE ? 1 : 0) * swipeFade + } + + return ( +
+ {/* Swatch — the stamp's frame color */} +
+ + {/* Title + subtitle */} +
+
+ {data.item.title} +
+
+ {data.item.subtitle} +
+
+ + {/* Close — stopPropagation so pressing it never starts a swipe. */} + +
+ ) +} diff --git a/site/components/demo-stamps.ts b/site/components/demo-stamps.ts index 1658429..6e568fd 100644 --- a/site/components/demo-stamps.ts +++ b/site/components/demo-stamps.ts @@ -1,18 +1,21 @@ export interface DemoItem { id: string - label: string + title: string + subtitle: string } -// Generic, domain-neutral stamps for the hero/demo fan. +// Postage-themed demo stamps for the hero/demo fan — a name + "subject · year", +// echoing a real stamp collection. Shown on the stamp face (title) and in the +// tap toast (title + subtitle). export const DEMO_ITEMS: DemoItem[] = [ - { id: 'one', label: 'One' }, - { id: 'two', label: 'Two' }, - { id: 'three', label: 'Three' }, - { id: 'four', label: 'Four' }, - { id: 'five', label: 'Five' }, - { id: 'six', label: 'Six' }, - { id: 'seven', label: 'Seven' }, - { id: 'eight', label: 'Eight' }, + { id: 'meridian', title: 'Meridian', subtitle: 'Brass compass · 1972' }, + { id: 'aurora', title: 'Aurora', subtitle: 'Northern lights · 1965' }, + { id: 'tidewater', title: 'Tidewater', subtitle: 'Coastal survey · 1958' }, + { id: 'lumen', title: 'Lumen', subtitle: 'Lighthouse · 1981' }, + { id: 'verdant', title: 'Verdant', subtitle: 'Fern study · 1969' }, + { id: 'cinder', title: 'Cinder', subtitle: 'Steam engine · 1954' }, + { id: 'halcyon', title: 'Halcyon', subtitle: 'Kingfisher · 1977' }, + { id: 'zephyr', title: 'Zephyr', subtitle: 'Hot-air balloon · 1963' }, ] const PALETTE = ['#295df6', '#c6a0fd', '#5cd500', '#ff7a45', '#ff3e8c', '#00c9a7']