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() {+ 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 dependencies beyond React 18. - -
- - -
- $ {COMMANDS[pm]}
-
- + {PROMPT} +
+
+ $ {COMMANDS[tab]}
+
+ {label}
{children}