proposal

💌 WillYou

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.


What it does

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.

Animation

Everything is CSS + the Web Animations API — no animation library.

The letter

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

Always 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.

The site

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.

Layout

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)

API

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.

Privacy & safety

Configuration

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.

Tests

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.

Deploying

It’s a single Node process with a file-backed database.

  1. Set DATA_DIR to a persistent volume — app.db and uploads/ must survive restarts.
  2. Put it behind TLS (trust proxy is already set to 1 for a single reverse proxy).
  3. Back up 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.

Split deploy (GitHub Pages + API host)

.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/:

Run it locally with API_BASE_URL=https://… CNAME=… npm run build:pages.

One-time setup

  1. Deploy the API and set DATA_DIR, ASSET_ORIGIN=https://<api-host> and ALLOWED_ORIGINS=https://propose.clicksolvertech.com on it.
  2. Repo → Settings → Pages → Source: GitHub Actions, custom domain propose.clicksolvertech.com, Enforce HTTPS on.
  3. Repo → Settings → Secrets and variables → Actions → Variables → add API_BASE_URL = https://<api-host>.
  4. DNS: CNAME propose YaswanthVanama123.github.io.

A note on intent

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.