From 3a18377dbca53bf4ea1ba52de88c719863e59e54 Mon Sep 17 00:00:00 2001
From: Lota Anidi
Date: Sat, 13 Jun 2026 22:54:38 +0100
Subject: [PATCH 1/3] Prompt tab: handle empty/new projects (scaffold Vite) +
default npm when no lockfile
---
site/components/InstallTabs.tsx | 17 ++++++++++++-----
1 file changed, 12 insertions(+), 5 deletions(-)
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
From b344c4c9a8a14e19cfb78e1c42700f1ddcb4e016 Mon Sep 17 00:00:00 2001
From: Lota Anidi
Date: Sat, 13 Jun 2026 23:28:49 +0100
Subject: [PATCH 2/3] Tap a stamp shows a toast (frame-color swatch + title +
subtitle); name the demo stamps
---
site/components/Hero.tsx | 25 ++++--
site/components/StampToast.tsx | 135 +++++++++++++++++++++++++++++++++
site/components/demo-stamps.ts | 23 +++---
3 files changed, 168 insertions(+), 15 deletions(-)
create mode 100644 site/components/StampToast.tsx
diff --git a/site/components/Hero.tsx b/site/components/Hero.tsx
index 6ca85d7..e1bff97 100644
--- a/site/components/Hero.tsx
+++ b/site/components/Hero.tsx
@@ -1,8 +1,12 @@
'use client'
+import { useState } from 'react'
import { StampStack } from 'stampstack'
-import { DEMO_ITEMS, demoColor } from './demo-stamps'
+import { DEMO_ITEMS, demoColor, type DemoItem } from './demo-stamps'
+import { StampToast } from './StampToast'
export function Hero() {
+ // The stamp tapped most recently — drives the toast (null = no toast shown).
+ const [toast, setToast] = useState(null)
return (
@@ -45,8 +49,8 @@ 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.
+ drag or flick the fan · arrow keys move focus · tap a stamp to open
)}
/>
+
+ {/* Tap a stamp → toast with its swatch color + title + subtitle. Keyed by id
+ so tapping a different stamp re-mounts and replays the enter animation. */}
+ {toast && (
+ setToast(null)}
+ />
+ )}
)
}
diff --git a/site/components/StampToast.tsx b/site/components/StampToast.tsx
new file mode 100644
index 0000000..aec5c47
--- /dev/null
+++ b/site/components/StampToast.tsx
@@ -0,0 +1,135 @@
+'use client'
+import { useCallback, useEffect, useState } from 'react'
+import type { DemoItem } from './demo-stamps'
+
+// A toast shown when a stamp is tapped: a swatch in the stamp's frame color,
+// the stamp title, and a muted subtitle, with a close button. Styled with the
+// site's tokens (var(--card)/--border/--muted/--font-ui) so it matches the page.
+//
+// Lifecycle: mounts hidden → rises + fades in on the next frame → auto-dismisses
+// after 4s (or on close), fading out before the parent unmounts it via onRemove.
+const ENTER_MS = 240
+const EXIT_MS = 180
+const AUTO_DISMISS_MS = 4000
+
+export function StampToast({
+ item,
+ color,
+ onRemove,
+}: {
+ item: DemoItem
+ color: string
+ onRemove: () => void
+}) {
+ const [show, setShow] = useState(false)
+
+ // Fade out, then let the parent unmount us after the exit transition.
+ const dismiss = useCallback(() => {
+ setShow(false)
+ const t = setTimeout(onRemove, EXIT_MS)
+ return () => clearTimeout(t)
+ }, [onRemove])
+
+ // Enter on the next frame (so the from-state paints first → transition runs).
+ useEffect(() => {
+ const r = requestAnimationFrame(() => setShow(true))
+ return () => cancelAnimationFrame(r)
+ }, [])
+
+ // Auto-dismiss.
+ useEffect(() => {
+ const t = setTimeout(dismiss, AUTO_DISMISS_MS)
+ return () => clearTimeout(t)
+ }, [dismiss])
+
+ return (
+
+ {/* Swatch — the stamp's frame color */}
+
+
+ {/* Title + subtitle */}
+
+
+ {item.title}
+
+
+ {item.subtitle}
+
+
+
+ {/* Close */}
+
+
+ )
+}
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']
From 61a50bb3899469a9f92fbd19e7386ed76e2410d8 Mon Sep 17 00:00:00 2001
From: Lota Anidi
Date: Sun, 14 Jun 2026 21:13:46 +0100
Subject: [PATCH 3/3] Hero fan: one-time fast intro sweep + Sonner-style
stacked toaster
- Replace the perpetual ping-pong attractor with a single rapid sweep on load
(right to the last stamp, back to the first), then stop. Aborts on user
interaction; skipped under reduced motion.
- Refactor the tap toast into a stacked StampToaster (newest in front, hover to
expand, swipe-down to dismiss, 4s auto-dismiss).
- Taller rainbow wash band on the page top.
---
site/app/globals.css | 2 +-
site/components/Hero.tsx | 79 +++++++++---
site/components/StampToast.tsx | 228 +++++++++++++++++++++++----------
3 files changed, 220 insertions(+), 89 deletions(-)
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 e1bff97..ecf2bb2 100644
--- a/site/components/Hero.tsx
+++ b/site/components/Hero.tsx
@@ -1,12 +1,56 @@
'use client'
-import { useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { StampStack } from 'stampstack'
-import { DEMO_ITEMS, demoColor, type DemoItem } from './demo-stamps'
-import { StampToast } from './StampToast'
+import { DEMO_ITEMS, demoColor } from './demo-stamps'
+import { StampToaster, type ToastData } from './StampToast'
export function Hero() {
- // The stamp tapped most recently — drives the toast (null = no toast shown).
- const [toast, setToast] = useState(null)
+ // 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 (
@@ -30,7 +74,7 @@ export function Hero() {
className="rise"
style={{ display: 'flex', alignItems: 'center', alignSelf: 'stretch', whiteSpace: 'nowrap', marginTop: -10, animationDelay: '160ms' }}
>
-
+ stampstackv0.2.0
@@ -49,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.
- drag or flick the fan · arrow keys move focus · tap a stamp to open
+ stampstack is a postage-styled 3D carousel component. install and drop in whatever content you want on the stamps. no deps beyond react 18
+
+
@@ -61,6 +106,7 @@ export function Hero() {
then spreads symmetrically and clips cleanly at both edges. */}
- {/* Tap a stamp → toast with its swatch color + title + subtitle. Keyed by id
- so tapping a different stamp re-mounts and replays the enter animation. */}
- {toast && (
- setToast(null)}
- />
- )}
+ {/* 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/StampToast.tsx b/site/components/StampToast.tsx
index aec5c47..6ffc223 100644
--- a/site/components/StampToast.tsx
+++ b/site/components/StampToast.tsx
@@ -1,114 +1,204 @@
'use client'
-import { useCallback, useEffect, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
import type { DemoItem } from './demo-stamps'
-// A toast shown when a stamp is tapped: a swatch in the stamp's frame color,
-// the stamp title, and a muted subtitle, with a close button. Styled with the
-// site's tokens (var(--card)/--border/--muted/--font-ui) so it matches the page.
-//
-// Lifecycle: mounts hidden → rises + fades in on the next frame → auto-dismisses
-// after 4s (or on close), fading out before the parent unmounts it via onRemove.
-const ENTER_MS = 240
-const EXIT_MS = 180
-const AUTO_DISMISS_MS = 4000
+// 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 function StampToast({
- item,
- color,
- onRemove,
+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,
}: {
- item: DemoItem
- color: string
- onRemove: () => void
+ toasts: ToastData[]
+ onDismiss: (key: number) => void
}) {
- const [show, setShow] = useState(false)
+ // 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)
+ }, [])
- // Fade out, then let the parent unmount us after the exit transition.
- const dismiss = useCallback(() => {
- setShow(false)
- const t = setTimeout(onRemove, EXIT_MS)
- return () => clearTimeout(t)
- }, [onRemove])
+ 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(() => setShow(true))
+ const r = requestAnimationFrame(() => setPhase('rest'))
return () => cancelAnimationFrame(r)
}, [])
- // Auto-dismiss.
+ // 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(() => {
- const t = setTimeout(dismiss, AUTO_DISMISS_MS)
+ if (phase !== 'rest' || expanded || dragging) return
+ const t = setTimeout(dismiss, AUTO_MS)
return () => clearTimeout(t)
- }, [dismiss])
+ }, [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 */}
-
- {item.title}
+
+ {data.item.title}
-
- {item.subtitle}
+
+ {data.item.subtitle}
- {/* Close */}
+ {/* Close — stopPropagation so pressing it never starts a swipe. */}