A web app for asking someone out. You pick a theme, write your message, add photos, and publish — you get a private link to send them, and a second private link that’s your own dashboard where their answer shows up.
Yes, what you asked for is entirely possible, and this is it: templates, image uploads, a shareable link, and answers coming back to you.
npm install
npm start # → http://localhost:3000
No database server, no accounts, no build step.
For the person asking
| 8 themes | Rose Garden, Starry Night, Sweet Bubbles, Polaroid Diary, Quiet Minimal, Neon Nights, Golden Hour, Player Two |
| Occasion presets | Six starting points (asking out, prom, valentine, anniversary, proposing, best friend) that prefill theme, headline, question and buttons |
| Live preview | A phone mock next to the form updates as you type |
| Photos | Up to 8, with captions, reorderable; re-encoded to WebP and stripped of EXIF/GPS |
| Your words | Headline, message, the question, and your own Yes/No button labels |
| Passcode | Optional — the page won’t open without it |
| Background music | Optional direct audio URL, never autoplays with sound until they tap “Open it” |
| Dashboard | Opens count, their answer, the note they left, edit the page, delete everything |
| Draft autosave | Your half-finished text survives a reload (kept in your browser only) |
| Share | Copy link, native share sheet, WhatsApp / Telegram / X / Email / SMS |
For the person being asked
An envelope they tap to open (flap swings open in 3D, the letter slides out), then the letter reveals itself line by line. Photos tilt as they move their cursor and drift with a soft parallax. They answer right there — a “yes” sets off a light bloom, expanding rings, confetti and floating hearts — and they can write a note back.
The playful “no” button dodges the cursor a few times and then settles, so a real “no” is always possible. That’s deliberate: the answer has to be able to be no.
Everything is CSS + the Web Animations API — no animation library.
The envelope, in five stages — a scripted sequence, tappable to skip:
| Stage | What happens |
|---|---|
| 0 | Closed envelope floating, wax seal intact, stamp + postmark + address lines, soft shadow beneath |
| 1 | Seal cracks free — pops, spins, flies toward you, throwing ten sparks outward |
| 2 | Flap swings open in 3D with an overshoot |
| 3 | Folded letter rises out of the envelope, tilting forward, shadow deepening |
| 4 | Letter unfolds — crease lines fade as it grows to full height, text lines appearing one by one |
| 5 | Letter flies past the camera, envelope fades, the real letter takes over |
Then the letter assembles itself: word-by-word headline, each block lifting in with a blur.
As they scroll
Gallery
2 / 5 counterAlways running, per theme
On “yes” White bloom flash, three expanding rings, canvas confetti, rising hearts, the result card springing in — and then tapping anywhere throws a burst of hearts from your finger.
| Section | Animation |
|---|---|
| Hero | Staggered entrance; a floating phone that parallaxes against the page and cycles four live letters |
| Marquee band | Seamless infinite ticker of real proposal lines, pauses on hover |
| From blank page to their answer | A pinned envelope stage with five scroll-driven scenes: letter out → wax seal stamping down with a shockwave ring → paper plane flying off with a drawn trail → flap reopening → a “Yes 💕” badge springing in with a spark burst. Decorative arcs draw themselves in as you go |
| Occasions | Six occasion cards with spotlight glow |
| What to write | Four rules, each with a side-by-side “instead of / try” example, revealing alternately |
| Before you send it | Consent and privacy guidance |
| How it works | Step cards reveal alternately from left and right, lift on hover |
| Watch it come together | A pinned phone that switches theme as you scroll through four steps; the active step lights up |
| Six letters, side by side | A 3D coverflow carousel — drag, swipe, arrow keys, click-to-front, dot navigation, auto-advance that pauses on hover |
| Theme grid | Live theme swatches revealing left/right, with a cursor-following spotlight |
| Figures | Numbers that count up when they scroll into view |
| Quotes | Cards revealing in three directions, tilting on hover, spotlight glow |
| FAQ | Blur-fade reveals, animated open/close |
| Footer | Hearts drifting upward behind the text |
| Everywhere | Header scroll-progress bar, ripples from the click point on primary buttons, cross-page view transitions |
| Builder | Animated panel slides, live preview, check-badge pop on theme select |
| Dashboard | Sections stagger in, stat numbers count up, spotlight cards |
All of it collapses under prefers-reduced-motion: reduce.
server.js boot
src/
app.js express app (exported so tests can drive it)
api.js all routes
config.js limits + the theme list
db.js node:sqlite schema (no native deps)
images.js sharp re-encode, magic-byte sniffing
util.js slugs, scrypt passcodes, text cleaning
public/
index.html landing page (rotating live demo)
create.html 5-step builder + live preview + share screen
view.html what the recipient opens
manage.html your dashboard
showcase.html opt-in public gallery
css/app.css design system
css/templates.css the 8 themes
css/motion.css animation layer
js/render.js one renderer, shared by the live page and the preview
js/motion.js reveal / tilt / parallax / typewriter / coverflow /
count-up / spotlight / ripple / heart-burst / marquee
js/effects.js ambient particles + confetti
test/
smoke.mjs 94 API + page checks
frontend.mjs 84 static checks on the browser bundle
inject.mjs in-process HTTP driver
demo.mjs prints a real end-to-end run with live links
data/ created at runtime: app.db + uploads/ (gitignored)
| Method | Path | Notes |
|---|---|---|
GET |
/api/config |
the 8 themes, field limits, upload limits |
POST |
/api/proposals |
returns the 8-character slug and your manageToken |
POST |
/api/proposals |
multipart; returns { slug, manageToken } |
GET |
/api/proposals/:slug |
?code= if locked; 401 {locked:true} otherwise |
POST |
/api/proposals/:slug/view |
opens counter |
POST |
/api/proposals/:slug/respond |
returns { responseId, answerToken } |
PATCH |
/api/proposals/:slug/respond |
attach a note using that token |
GET |
/api/manage/:slug |
needs x-manage-token |
PATCH |
/api/manage/:slug |
edit text, theme, visibility |
DELETE |
/api/manage/:slug |
removes rows and image files |
GET |
/api/showcase |
public, non-passcoded proposals only |
Publishing generates an 8-character code and two links on your own domain:
share with them yourdomain.com/K7m2Px9Q ← opens the letter directly
your dashboard yourdomain.com/m/K7m2Px9Q#<manageToken>
yourdomain.com/p/K7m2Px9Q also works, so any link shared before this change keeps working.
The code is 8 characters drawn from a 56-character alphabet — digits and letters with
0 O 1 l I removed so nothing is ambiguous when read aloud or retyped. That is 46.5 bits
of entropy, about 97 trillion combinations, generated with crypto.randomInt (no modulo
bias). Guessing one is not practical, and the read endpoint is rate limited on top of that.
Root-level paths only resolve to a letter when they match that alphabet and length, so
/create, /showcase, /favicon.svg and the landing page can never be shadowed; a
reserved-word list blocks future paths like /settings and /register too.
Pages: /, /create, /showcase, /:slug, /p/:slug, /m/:slug#<manageToken>.
The manage token lives in the URL fragment, so it is never sent to the server in a Referer header or written to access logs.
noindex and robots.txt disallows everything except the marketing pagesContent-Type, then re-encoded (which drops EXIF/GPS)textContent — never innerHTML. A CSP with script-src 'self' backs that up| Env var | Default |
|---|---|
PORT |
3000 |
DATA_DIR |
./data |
ASSET_ORIGIN |
(empty — relative /uploads/…) |
ALLOWED_ORIGINS |
(empty — same-origin only) |
The last two only matter when the frontend is hosted separately (see
Split deploy). ASSET_ORIGIN makes image URLs
absolute; ALLOWED_ORIGINS is a comma-separated CORS allowlist.
Field lengths, MAX_IMAGES (8) and MAX_FILE_BYTES (8 MB) live in src/config.js.
npm test # both suites (178 checks)
npm run test:api # API + pages
npm run check # static checks on the browser bundle
test/smoke.mjs drives the real Express app through test/inject.mjs, which builds
genuine IncomingMessage/ServerResponse pairs in-process — so the suite needs no
open port and exercises middleware, multer, static files and error handling for real.
test/frontend.mjs catches the things that fail silently in the browser: module syntax,
cross-module imports, every #id a page script reaches for, asset paths, CSS/JS class
agreement (both directions — every class in the HTML and every class applied from JS must
have a rule), keyframe resolution, that every scroll-reveal variant has a matching CSS rule,
that reveal / tilt / parallax each own a different CSS property so they can’t overwrite
each other, that every envelope stage and journey scene has a matching CSS rule, and that
theme ids are never hardcoded in the browser code.
Not covered: the animations have not been run in a real browser — the checks above are static. Do a visual pass yourself before sending anyone a link.
It’s a single Node process with a file-backed database.
DATA_DIR to a persistent volume — app.db and uploads/ must survive restarts.trust proxy is already set to 1 for a single reverse proxy).DATA_DIR; a lost dashboard link is unrecoverable by design, but a lost
database takes everyone’s proposals with it.For real traffic, serve /uploads from a CDN or object store rather than the app.
.github/workflows/deploy-pages.yml publishes public/ to GitHub Pages at
propose.clicksolvertech.com on every push to main. Pages is static only, so the
Express API must live somewhere that runs Node with a persistent disk.
tools/build-pages.mjs produces _site/ from public/:
js/config.js so every fetch targets API_BASE_URL instead of same-origin<meta> tags Pages can servecreate/index.html and showcase/index.html for the extensionless routes404.html into a router for /p/:slug, /m/:slug and /:slug, which have no
static file to land onCNAME and .nojekyllRun it locally with API_BASE_URL=https://… CNAME=… npm run build:pages.
One-time setup
DATA_DIR, ASSET_ORIGIN=https://<api-host> and
ALLOWED_ORIGINS=https://propose.clicksolvertech.com on it.propose.clicksolvertech.com, Enforce HTTPS on.API_BASE_URL = https://<api-host>.CNAME propose YaswanthVanama123.github.io.This is for asking someone a question, not for pressuring them. The dodging “no” button always gives way, a “no” is recorded and reported plainly, and every page can be deleted by its creator at any time. Ask once, and take the answer you’re given.