diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5f5e192 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +# Run on pushes to main and on every pull request targeting main, +# so PRs are validated before merge. +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + # The library lives at the repo root: typecheck, run the vitest suite, build. + library: + name: Library (typecheck · test · build) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 20 + cache: npm + cache-dependency-path: package-lock.json + - run: npm ci + - run: npm run typecheck + - run: npm test + - run: npm run build + + # The docs site lives in /site and consumes the published stampstack from npm, + # so it builds independently of the library job. No tests here — typecheck + build. + site: + name: Site (typecheck · build) + runs-on: ubuntu-latest + defaults: + run: + working-directory: site + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 20 + cache: npm + cache-dependency-path: site/package-lock.json + - run: npm ci + - run: npm run typecheck + - run: npm run build diff --git a/site/.gitignore b/site/.gitignore index 0665742..725b783 100644 --- a/site/.gitignore +++ b/site/.gitignore @@ -1,3 +1,4 @@ node_modules .next next-env.d.ts +.vercel diff --git a/site/app/fonts.ts b/site/app/fonts.ts index a68d2f8..013fff9 100644 --- a/site/app/fonts.ts +++ b/site/app/fonts.ts @@ -1,5 +1,4 @@ import localFont from 'next/font/local' -import { Geist_Mono } from 'next/font/google' export const openRunde = localFont({ src: [ @@ -11,9 +10,3 @@ export const openRunde = localFont({ variable: '--font-open-runde', display: 'swap', }) - -export const geistMono = Geist_Mono({ - subsets: ['latin'], - variable: '--font-geist-mono', - display: 'swap', -}) diff --git a/site/app/globals.css b/site/app/globals.css index f5dc1e9..0f5810b 100644 --- a/site/app/globals.css +++ b/site/app/globals.css @@ -3,8 +3,8 @@ --card: #ffffff; --text: #333333; --muted: #8a8a8a; - --border: #ececec; - --accent: #295df6; + --border: #E5E5E5; + --accent: #1a1a1a; /* dark neutral — no blue accent text */ /* The `, system-ui` fallback inside var() matters: if the next/font variable isn't resolved yet, var() would otherwise invalidate font-family → serif. */ @@ -13,7 +13,7 @@ Cascadia first (modern, if installed), Consolas as the guaranteed catch-all. */ --font-mono: "SF Mono", SFMono-Regular, ui-monospace, "Cascadia Code", "Cascadia Mono", Consolas, "Courier New", monospace; - --maxw: 640px; + --maxw: 560px; } * { box-sizing: border-box; } @@ -33,20 +33,26 @@ html, body { overflow-x: clip; } -/* A thin gradient hairline along the very top — the one playful accent. */ +/* A soft, blurred rainbow wash across the very top — the one playful accent. + Figma treatment (node 1787:9551): a ~13px band heavily blurred (33px) and + dropped to 20% opacity, so the brand rainbow blooms into a pastel glow. + pointer-events:none so the bleed never blocks clicks on the header below. */ body::before { content: ''; position: fixed; top: 0; left: 0; right: 0; - height: 3px; + height: 14px; background: linear-gradient(90deg, #295df6, #c6a0fd, #5cd500, #ff7a45, #ff3e8c, #00c9a7); + filter: blur(33px); + opacity: 0.2; + pointer-events: none; z-index: 100; } main { max-width: var(--maxw); margin: 0 auto; - padding: 0 20px 120px; + padding: 0 20px 30px; } a { color: var(--accent); text-decoration: none; } @@ -55,12 +61,23 @@ a:hover { text-decoration: underline; } /* Button-style links (Figma nav buttons) — no underline / opacity fade. */ .btn:hover { text-decoration: none; opacity: 1; } +/* Quiet inline links (footer) — sit in the surrounding grey at rest, darken on + hover via the a:hover rule. Color lives here (not inline) so :hover can win. */ +.link-quiet { color: inherit; text-decoration: underline; } + +/* Byline link (the name) — same quiet grey + hover-darken, but never underlined. + The :hover rule overrides the base a:hover underline (equal specificity, later wins). */ +.link-plain { color: inherit; } +/* Base text here (#484747) is already dark, so var(--text) barely reads as a + change. Darken to the brand's darkest neutral (#1a1a1a) for a visible hover. */ +.link-plain:hover { color: var(--accent); text-decoration: none; } + /* The quiet section label — the haptics signature. */ .section-label { font-size: 13px; - font-weight: 500; + font-weight: 600; letter-spacing: -0.005em; - color: var(--muted); + color: #252525; /* match the Outro label (Footer.tsx) */ margin: 0 0 14px; } @@ -74,13 +91,17 @@ code, pre, .code-card pre, .code-card code { position: relative; background: var(--card); border: 1px solid var(--border); - border-radius: 10px; + border-radius: 15px; overflow: hidden; } .code-card pre { margin: 0; padding: 16px 18px; - overflow-x: auto; + /* Reflow long lines inside the box instead of scrolling sideways: + pre-wrap keeps indentation + intentional breaks but wraps when too wide; + break-word lets a single long token (e.g. a path) break rather than overflow. */ + white-space: pre-wrap; + overflow-wrap: break-word; background: var(--card) !important; font-family: var(--font-mono); font-size: 13.5px; @@ -96,21 +117,26 @@ code, pre, .code-card pre, .code-card code { :root { --ease-out: cubic-bezier(0.23, 1, 0.32, 1); } /* strong ease-out */ /* Smooth state changes (color/bg swaps animate via the transition). */ -.ss-tap { transition: transform 0.14s var(--ease-out), box-shadow 0.2s ease, color 0.18s ease, background-color 0.18s ease, border-color 0.18s ease; } -.ss-tab { transition: color 0.18s ease, opacity 0.18s ease, transform 0.14s var(--ease-out); } +.ss-tap { transition: transform 0.14s var(--ease-out), color 0.18s ease, background-color 0.18s ease, border-color 0.18s ease; } +.ss-tab { color: var(--muted); transition: color 0.18s ease, opacity 0.18s ease, transform 0.14s var(--ease-out); } +.ss-tab.is-active { color: var(--text); } /* selected tab — colored via class so :hover can win */ a { transition: opacity 0.16s ease, color 0.18s ease; } -/* Press feedback — every pointer type, motion-safe. */ -@media (prefers-reduced-motion: no-preference) { - .ss-tap:active { transform: scale(0.97); box-shadow: none; } - .ss-tab:active { transform: scale(0.95); } +/* Hover — only true hover devices, so taps on touch don't trigger it. + MUST come before :active below — equal specificity means source order wins, + so :active needs to be last to override the hover scale during a press. */ +@media (prefers-reduced-motion: no-preference) and (hover: hover) and (pointer: fine) { + .ss-tap:hover { transform: scale(1.02); } + /* Darken on hover (toward the main text color), not fade — matches the Copy button. */ + .ss-tab:hover { color: var(--text); } + a:hover { color: var(--text); } } -/* Hover — only true hover devices, so taps on touch don't trigger it. */ -@media (prefers-reduced-motion: no-preference) and (hover: hover) and (pointer: fine) { - .ss-tap:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); } - .ss-tab:hover { opacity: 0.6; } - a:hover { opacity: 0.6; } +/* Press feedback — every pointer type, motion-safe. Last so it wins over :hover + when the pointer is both hovering and pressing (the LVHA cascade order). */ +@media (prefers-reduced-motion: no-preference) { + .ss-tap:active { transform: scale(0.97); } + .ss-tab:active { transform: scale(0.95); } } /* Scroll-reveal: fade + rise into view (one-shot via IntersectionObserver). */ @@ -133,3 +159,40 @@ a { transition: opacity 0.16s ease, color 0.18s ease; } } @keyframes ss-rise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } } @keyframes ss-fade { from { opacity: 0; } to { opacity: 1; } } + +/* Copy buttons: no lift, no shadow. Hover darkens the text; the "Copied" + label eases in on click. */ +.copy-btn { + color: var(--muted); + transition: color 0.18s ease-in; +} +@media (hover: hover) and (pointer: fine) { + .copy-btn:hover { + color: var(--text); + } +} +.copy-btn.is-copied { + color: var(--accent); +} +.copy-label { + display: inline-block; +} +@media (prefers-reduced-motion: no-preference) { + .copy-label { + animation: copy-in 0.2s ease-in; + } +} +@keyframes copy-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Vertically-centered Copy button in single-line boxes (Install). */ +.copy-center { + top: 50%; + transform: translateY(-50%); +} diff --git a/site/app/layout.tsx b/site/app/layout.tsx index fe83f37..a3fb68a 100644 --- a/site/app/layout.tsx +++ b/site/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next' import './globals.css' import 'stampstack/styles.css' -import { openRunde, geistMono } from './fonts' +import { openRunde } from './fonts' export const metadata: Metadata = { title: 'stampstack — a draggable 3D coverflow of postage-stamp cards', @@ -11,7 +11,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} ) diff --git a/site/app/page.tsx b/site/app/page.tsx index 992f7ea..45abb53 100644 --- a/site/app/page.tsx +++ b/site/app/page.tsx @@ -2,6 +2,7 @@ import { Hero } from '@/components/Hero' import { Section } from '@/components/Section' import { Reveal } from '@/components/Reveal' import { InstallTabs } from '@/components/InstallTabs' +import { ActionButtons } from '@/components/ActionButtons' import { CodeBlock } from '@/components/CodeBlock' import { Footer } from '@/components/Footer' @@ -27,8 +28,9 @@ export default function Home() { -
+
+
diff --git a/site/components/ActionButtons.tsx b/site/components/ActionButtons.tsx new file mode 100644 index 0000000..5643abf --- /dev/null +++ b/site/components/ActionButtons.tsx @@ -0,0 +1,42 @@ +import type { CSSProperties } from 'react' + +// Button styles mirrored 1:1 from Figma (node 1704:513). +const btnBase: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: 9, + height: 36, + padding: '8px 16px', + borderRadius: 11, + fontFamily: 'var(--font-ui)', + fontWeight: 500, + fontSize: 14, + letterSpacing: '-0.5px', + lineHeight: '20px', + whiteSpace: 'nowrap', +} +const primaryBtn: CSSProperties = { ...btnBase, background: '#171717', color: '#fafafa', border: '1px solid #171717' } +const secondaryBtn: CSSProperties = { ...btnBase, background: 'transparent', color: '#0a0a0a', border: '1px solid #e5e5e5' } + +export function ActionButtons() { + return ( +
+ + {/* eslint-disable-next-line @next/next/no-img-element */} + + Star on GitHub + + + {/* eslint-disable-next-line @next/next/no-img-element */} + + View docs + + + {/* eslint-disable-next-line @next/next/no-img-element */} + + Follow + +
+ ) +} diff --git a/site/components/CopyButton.tsx b/site/components/CopyButton.tsx index 5691d54..eebbb11 100644 --- a/site/components/CopyButton.tsx +++ b/site/components/CopyButton.tsx @@ -1,11 +1,11 @@ 'use client' import { useState } from 'react' -export function CopyButton({ text }: { text: string }) { +export function CopyButton({ text, center = false }: { text: string; center?: boolean }) { const [copied, setCopied] = useState(false) return ( ) } diff --git a/site/components/Footer.tsx b/site/components/Footer.tsx index 6667d1e..8101dd1 100644 --- a/site/components/Footer.tsx +++ b/site/components/Footer.tsx @@ -1,4 +1,15 @@ export function Footer() { + // Server component → these evaluate at build time, so "Last updated" reflects + // the most recent deploy and auto-advances on every build. + const now = new Date() + const year = now.getFullYear() + const lastUpdated = now.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }) + return (
Drag / flick release behavior adapted from{' '} Swiper @@ -94,8 +105,18 @@ export function Footer() { color: '#484747', }} > - © 2026 Made by Lota - Last updated: June 13, 2026 + + © {year} Made by{' '} + + Lota Anidi + + + Last updated: {lastUpdated}
diff --git a/site/components/Hero.tsx b/site/components/Hero.tsx index 7c73023..6ca85d7 100644 --- a/site/components/Hero.tsx +++ b/site/components/Hero.tsx @@ -1,48 +1,60 @@ 'use client' -import type { CSSProperties } from 'react' import { StampStack } from 'stampstack' import { DEMO_ITEMS, demoColor } from './demo-stamps' -// Button styles mirrored 1:1 from Figma (node 1704:513). -const btnBase: CSSProperties = { - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - gap: 9, - height: 36, - padding: '8px 16px', - borderRadius: 11, - fontFamily: 'var(--font-ui)', - fontWeight: 500, - fontSize: 14, - letterSpacing: '-0.5px', - lineHeight: '20px', - whiteSpace: 'nowrap', -} -const primaryBtn: CSSProperties = { ...btnBase, background: '#171717', color: '#fafafa', border: '1px solid #171717' } -const secondaryBtn: CSSProperties = { ...btnBase, background: '#fafafa', color: '#0a0a0a', border: '1px solid #e5e5e5' } - export function Hero() { return (
+ +
+ {/* Logo → (wordmark + GitHub badge) → description */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + stampstack + + {/* Wordmark + GitHub badge share one full-width row: align-items:center + vertically centers the badge on the wordmark, and marginLeft:auto + pins it to the content's right edge. */} + + +

+ stampstack is a postage-styled 3D carousel component. Install and drop in whatever content you want on the stamps. + no dependencies beyond React 18. +

+
+
+ {/* Live, draggable fan — the product is the hero. The library places the focused card at the left of its root, so we give StampStack a narrow width (= cardWidth) and center it in a wider clipping container; the fan then spreads symmetrically and clips cleanly at both edges. */} - -

- stampstack -

-

- stampstack is a postage-styled 3D carousel component. Install and drop in whatever content you want on the stamps. - no dependencies beyond React 18. - -

- -
-
- - {/* eslint-disable-next-line @next/next/no-img-element */} - - Star on GitHub - - - {/* eslint-disable-next-line @next/next/no-img-element */} - - View docs - - - {/* eslint-disable-next-line @next/next/no-img-element */} - - Follow - -
) } diff --git a/site/components/InstallTabs.tsx b/site/components/InstallTabs.tsx index acd095c..55ca9d0 100644 --- a/site/components/InstallTabs.tsx +++ b/site/components/InstallTabs.tsx @@ -9,16 +9,34 @@ 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. + +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: + import { StampStack } from 'stampstack' + import 'stampstack/styles.css' +3. Render it with your data — each item only needs an id: +
{item.title}
} /> +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.` + +const TABS = ['npm', 'pnpm', 'yarn', 'bun', 'Prompt'] as const + export function InstallTabs() { - const [pm, setPm] = useState('npm') + const [tab, setTab] = useState<(typeof TABS)[number]>('npm') + const isPrompt = tab === 'Prompt' + return (
- {Object.keys(COMMANDS).map((key) => ( + {TABS.map((key) => ( ))}
-
-
-          $ {COMMANDS[pm]}
-        
- -
+ + {isPrompt ? ( +
+

+ {PROMPT} +

+ +
+ ) : ( +
+
+            $ {COMMANDS[tab]}
+          
+ +
+ )}
) } diff --git a/site/components/Section.tsx b/site/components/Section.tsx index 1b05b0e..4900d0f 100644 --- a/site/components/Section.tsx +++ b/site/components/Section.tsx @@ -1,6 +1,14 @@ -export function Section({ label, children }: { label: string; children: React.ReactNode }) { +export function Section({ + label, + children, + marginTop = 72, +}: { + label: string + children: React.ReactNode + marginTop?: number +}) { return ( -
+

{label}

{children}
diff --git a/site/public/icons/github-badge.svg b/site/public/icons/github-badge.svg new file mode 100644 index 0000000..9c129f4 --- /dev/null +++ b/site/public/icons/github-badge.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/site/public/icons/stampstack-logo.svg b/site/public/icons/stampstack-logo.svg new file mode 100644 index 0000000..a0158eb --- /dev/null +++ b/site/public/icons/stampstack-logo.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + +