Don’t just send it. Unveil it. Unveil is a personal celebration and surprise platform. People use it to build an interactive digital surprise (photos, videos, voice notes, letters, puzzles, games, countdowns and animated reveals) for someone they care about, then send it as a private link. The person receiving it doesn’t need an account or an app: they open the link, tap, unwrap and play through a story made just for them.
A text message is read in a second and forgotten in a scroll. Unveil turns the same words into a small ceremony: a gift to unwrap, balloons to pop, a code to crack, photos that reveal one at a time, a letter that types itself out, and a final surprise at the end. The sender builds it in minutes from a template (or from scratch), adds their own memories, and shares a private link.
| Role | Needs an account? | What they do |
|---|---|---|
| Visitor / guest | No | Reads the landing and About pages, and tries Quick Create at /create up to the preview. Signing up keeps their draft. |
| Creator (a signed-up user) | Yes | Keeps people, occasions and memories; builds, previews, schedules, publishes and shares experiences; sees responses; invites friends to contribute. |
| Collaborator (editor or viewer) | Yes | Is added to someone else’s experience. Viewers can see it; editors will be able to edit it (the builder currently shows them a “coming soon” screen). |
| Recipient | No | Opens their personal link and plays through the surprise. Answers, hints and completion are recorded for the creator. |
| Contributor (a friend) | No | Opens an invite or group link and adds messages, photos, videos, memories, answers or voice notes for the creator to approve. |
| Admin | Yes, plus an app_admins row |
Uses the admin console: analytics, moderation, templates, gift products, payments and coupons, the About page, AI settings and the audit log. |
| Area | What it gives people |
|---|---|
| People and occasions | A private list of loved ones (photos, relationship, birthday, favourite things, notes) and the days worth celebrating, with yearly recurrence, a calendar and “coming up” reminders on the dashboard. |
| Memory Vault | A private home for photos, videos, voice notes, stories, quotes and links, with a timeline, filters, duplicate detection, and AI memory stories that weave chosen memories into one narrative. |
| Quick Create | Five guided steps: who it’s for → pick an experience → personalise → preview → publish. No builder needed, and guests can try it without an account. |
| Template library | 31 ready-made interactive stories across birthdays, romance, anniversaries, friendship, family, graduation, thank-yous, farewells, apologies and “just because”. |
| Experience Builder | A full visual editor: pages and interactive scenes, 15 block types, drag and drop, themes, animations, background music, stickers, autosave and validation before publishing. |
| Interactive scenes | Balloons, piñatas, scratch cards, combination locks, jigsaws, hidden hunts, flip cards, “Will you…?” questions where No runs away, sign-and-stamp promises, letters and final reveals. |
| Sharing | One private link per recipient, a device share sheet, live responses, and opens and completion tracked. |
| Scheduling and Surprise Drop | Open at a chosen time in a chosen time zone, close automatically, or release the story in up to 10 timed stages with countdowns between them. |
| Friends and Group Secret | Personal or group invite links for friends to contribute, owner approval, and a surprise that stays completely hidden from the recipient until the reveal. |
| AI help (optional) | Help me write, AI edits of any block, a full experience generator, quiz and puzzle generation, and memory stories. Everything also works without AI. |
| Payments (optional) | A price per experience with Razorpay checkout, coupons and gift products before links open. |
| Admin console | Analytics, users and experiences moderation, reports, templates, gift products, payments, coupons, subscriptions, the About page, AI usage, system health and a tamper-resistant audit log with an audited purge option. |
| Look and feel | A warm, premium design, light and dark mode, mobile-first layouts, reduced-motion support and keyboard-friendly interactions. |
| Term | Meaning |
|---|---|
| Experience | The surprise itself: a titled, themed set of pages. Stored as experiences → experience_pages → experience_blocks. |
| Block | One piece of content on a page: text, photo, video, audio, quote, message, question, quiz, puzzle, reveal, countdown, button, divider, memory or contributions. |
| Scene | A page that plays as one interactive step of a story (for example “pop the balloons”). An experience with any scenes plays as a story. |
| Template | A ready-made story (scenes with {placeholders}) that is copied into a new experience. Editing the copy never changes the template. |
| Recipient link | /x/<code> (7 letters and digits): a private, hard-to-guess link for one person. Opening it counts as their visit. |
| Stage / Surprise Drop | Groups of pages that unlock at later times, each with its own countdown. |
| Group Secret | A group surprise that the recipient can’t see at all until its reveal time, while friends keep contributing. |
| Contribution | Something a friend sends through an invite link; it appears only after the creator approves it. |
| Memory | An item in the Memory Vault. Experiences reference memories by id; files are never copied. |
flowchart TD
A([Visitor lands on Unveil]) --> B{Has an account?}
B -- No --> C["Try Quick Create at /create<br/>(who, experience, personalise, preview)"]
C --> D[Sign up or sign in]
D --> E[Onboarding: name, photo, time zone]
E --> F["Guest draft carried over<br/>to Quick Create"]
B -- Yes --> G[Dashboard]
G --> H{How to create?}
H -- Quick --> F
H -- Template --> I[Template library]
H -- From scratch --> J[New experience]
H -- With AI --> K[AI experience generator]
F --> L[Personalise and preview]
I --> M[Experience Builder]
J --> M
K --> M
L -- Customize more --> M
M --> N{Publish now or schedule?}
L --> N
N --> O{Payments on?}
O -- Yes --> P["Checkout: gifts, coupon, Razorpay"]
O -- No --> Q[Links unlocked]
P --> Q
Q --> R[Copy or share each recipient's private link]
R --> S["Watch responses, opens and completion"]
R --> T[Optional: invite friends to contribute]
flowchart TD
A(["Recipient opens /x/code"]) --> B{Is the link valid and live?}
B -- "Invalid, private, removed or unrevealed secret" --> X["Something special is on its way"]
B -- "Scheduled for later" --> Y["Countdown: Your surprise arrives in…<br/>opens by itself"]
B -- "Closed / expired" --> Z[This surprise has closed]
B -- "Payment still needed" --> X
B -- Live --> C["Welcome screen<br/>Open your surprise"]
C --> D["Gift unwraps; pages load<br/>(opened_at recorded)"]
D --> E["Play scenes: tap, pop, scratch,<br/>answer, solve, reveal"]
E --> F{More stages locked?}
F -- Yes --> G["Your next surprise arrives in…<br/>countdown, then continues"]
G --> E
F -- No --> H["Final reveal<br/>(completed_at recorded)"]
H --> I[Replay or start over]
flowchart LR
A[Creator creates invite links] --> B["Personal link per friend<br/>or one group link"]
B --> C["Friend opens /c/code<br/>(no account)"]
C --> D["Sends message, photo, video,<br/>memory, answer or voice note"]
D --> E[(Pending)]
E --> F{Creator reviews}
F -- Approve --> G["Shown in the Contributions block<br/>when that page opens"]
F -- Reject or hide --> H[Not shown to the recipient]
Unveil is a single-page React app talking directly to Supabase. Row Level Security in Postgres is the authorisation layer for everything a user owns. Work that needs secrets or must not be trusted to the browser (AI, admin actions, payments, recipient and contributor links) runs in Postgres security-definer functions or Supabase Edge Functions.
flowchart LR
subgraph Browser["Browser (React 19 SPA, Vite)"]
UI[Pages and components]
RQ[TanStack Query cache]
SB[supabase-js client<br/>anon key only]
end
subgraph Supabase
AUTH[Auth<br/>email + password, PKCE]
PG[(Postgres<br/>RLS, triggers, RPCs)]
ST[(Storage<br/>avatars, memories,<br/>contributions, site-assets)]
VAULT[(Vault<br/>AI and Razorpay secrets)]
subgraph EF[Edge Functions - Deno]
AIG[ai-generate]
AIA[ai-admin]
ADM[admin-console]
PAY[payments]
WH[payments-webhook]
end
end
AIP[("Gemini / OpenAI /<br/>Anthropic APIs")]
RZP[(Razorpay)]
UI --> RQ --> SB
SB -->|sign in, sessions| AUTH
SB -->|tables under RLS,<br/>token-checked RPCs| PG
SB -->|signed URLs,<br/>owner-folder uploads| ST
SB -->|JWT| EF
AIG --> AIP
AIA --> AIP
PAY --> RZP
RZP -->|order.paid,<br/>payment.captured| WH
EF -->|service role| PG
EF --> VAULT
| Kind of request | Path | Why |
|---|---|---|
| A user’s own data (people, occasions, memories, experiences, stories) | Browser → Postgres tables | RLS policies and column grants allow only the owner (and collaborators where designed). |
| Recipient links and contributor links (no account) | Browser (anon) → security-definer RPCs | The RPC checks the token, the schedule, moderation and payment status, and strips hidden answers before returning anything. |
| Private files | Browser → Storage with short-lived signed URLs | Buckets are private and policies check the owner folder or the token-issued upload ticket. |
| AI | Browser → ai-generate / ai-admin → provider |
Keys stay in Vault; the server checks plan, task and usage limits and validates every reply. |
| Admin console | Browser → admin-console |
Every call re-checks app_admins; admin tables have no browser policies; every change is audited. |
| Payments | Browser → payments → Razorpay; Razorpay → payments-webhook |
Amounts are computed on the server; payment signatures are verified before an experience is unlocked. |
erDiagram
AUTH_USERS ||--|| PROFILES : has
AUTH_USERS ||--o{ PEOPLE : owns
AUTH_USERS ||--o{ OCCASIONS : owns
AUTH_USERS ||--o{ MEMORIES : owns
AUTH_USERS ||--o{ MEMORY_STORIES : owns
AUTH_USERS ||--o{ EXPERIENCES : owns
PEOPLE ||--o{ OCCASIONS : "celebrated on"
PEOPLE ||--o{ MEMORIES : "appears in"
PEOPLE ||--o{ EXPERIENCES : "made for"
OCCASIONS ||--o{ EXPERIENCES : "made for"
EXPERIENCES ||--o{ EXPERIENCE_STAGES : "drops in"
EXPERIENCES ||--o{ EXPERIENCE_PAGES : contains
EXPERIENCE_STAGES ||--o{ EXPERIENCE_PAGES : groups
EXPERIENCE_PAGES ||--o{ EXPERIENCE_BLOCKS : contains
MEMORIES ||--o{ EXPERIENCE_BLOCKS : "referenced by id"
EXPERIENCES ||--o{ EXPERIENCE_RECIPIENTS : "shared with"
EXPERIENCES ||--o{ EXPERIENCE_CONTRIBUTORS : "collaborators"
EXPERIENCE_RECIPIENTS ||--o{ EXPERIENCE_INTERACTIONS : answers
EXPERIENCE_BLOCKS ||--o{ EXPERIENCE_INTERACTIONS : "answered on"
EXPERIENCES ||--o{ EXPERIENCE_INVITATIONS : invites
EXPERIENCES ||--o{ EXPERIENCE_CONTRIBUTION_PROMPTS : asks
EXPERIENCE_INVITATIONS ||--o{ EXPERIENCE_SUBMISSIONS : sends
EXPERIENCES ||--o{ EXPERIENCE_ORDERS : "paid by"
EXPERIENCES ||--o| EXPERIENCE_UNLOCKS : "unlocked by"
COUPONS ||--o{ EXPERIENCE_ORDERS : discounts
Admin, AI and site tables (app_admins, admin_audit_log, user_moderation, experience_moderation, experience_reports, template_settings, gift_products, payment_settings, ai_*, site_about) have RLS with no browser policies (except public reads where noted) and are reached through Edge Functions. Every table is described in its feature section below, and every migration is listed under Supabase.
| Concern | Choice |
|---|---|
| Build / dev | Vite 7 (Node 20.19 or later) |
| UI | React 19 + TypeScript (strict, noUncheckedIndexedAccess) |
| Routing | React Router 7 (data router, lazily loaded pages) |
| Styling | Tailwind CSS 4 with design tokens in src/styles/index.css |
| Server state | TanStack Query 5 |
| Backend | Supabase (Postgres, Auth, Storage, Vault, Edge Functions) with Row Level Security |
| Edge Functions | Deno (TypeScript), bundled with esbuild for the Dashboard editor |
| AI providers | Gemini, OpenAI, Anthropic (optional, server-side only) |
| Payments | Razorpay (optional) |
| Icons | lucide-react |
| Animation | motion (motion/react, lazy domAnimation), canvas-confetti (loaded on first use); Web Audio for sound effects and music |
| Tests | Node’s built-in test runner (node --test) for app logic and Edge Functions; SQL verification scripts for RLS |
flowchart LR
A[Install Node 20.19+<br/>and npm install] --> B[Create a Supabase project]
B --> C[Copy URL and anon key<br/>into .env.local]
C --> D[Run migrations 001-022<br/>in the SQL Editor]
D --> E[Set Auth Site URL<br/>and Redirect URLs]
E --> F[Deploy the five<br/>Edge Functions]
F --> G[npm run dev<br/>and sign up]
G --> H[Make yourself an admin]
H --> I[Optional: AI keys,<br/>Razorpay, templates,<br/>About page]
npm install
cp .env.example .env.local # then fill in values (PowerShell: Copy-Item .env.example .env.local)
npm run dev # http://localhost:5173
Environment variables (.env.local). Only public values belong here; everything prefixed VITE_ is bundled into the browser.
| Variable | Where to find it |
|---|---|
VITE_SUPABASE_URL |
Supabase Dashboard → Project Settings → API → Project URL (local: printed by npx supabase start) |
VITE_SUPABASE_ANON_KEY |
Same page, the anon / publishable key. Never the service-role or secret key: the app refuses to start with one. |
VITE_SITE_URL |
Optional locally, set it in production: the public address of the app, e.g. https://unveil.example.com. It makes link-preview URLs absolute (Installable app and link previews). |
Server secrets (service role, AI keys, ALLOWED_ORIGINS) are set on the Edge Functions, not here. Razorpay keys are saved in Admin → Payments.
Hosting the frontend: npm run build writes a static site to dist/. The included vercel.json configures Vercel to serve /index.html for direct visits to client-side routes (for example, /x/RPCdah). Other static hosts need an equivalent SPA fallback. Set the VITE_ variables at build time (including VITE_SITE_URL), serve /sw.js and /index.html with Cache-Control: no-cache so updates reach installed apps, add the site’s origin to ALLOWED_ORIGINS, and add its URL to the Supabase Auth Redirect URLs. The site must be on HTTPS to be installable.
Hosted project: create a project at supabase.com. Copy the Project URL and the anon / publishable key from Project Settings → API into .env.local. Then apply the migrations by running each file in supabase/migrations in numeric order (001_…, 002_…, …, 023_…) in the SQL Editor. Each file runs exactly once; never edit a file that has already been run. Schema changes always go in a new, higher-numbered file.
| Script | Purpose |
|---|---|
001_foundation_profiles.sql |
profiles table, RLS, updated_at trigger, sign-up trigger |
002_storage_avatars.sql |
Private avatars bucket and owner-folder storage policies |
003_profiles_onboarding.sql |
Profile fields (username, bio, timezone, onboarding) and validation |
004_people.sql |
people table, relationship_type enum, RLS, column grants, normalisation trigger |
005_occasions.sql |
occasions table, occasion_type enum, owner-checked person link, RLS, column grants, normalisation trigger |
006_memory_vault.sql |
memories table, memory_type enum, owner-checked person link, RLS, column grants, private memories bucket and storage policies (needs Postgres 15+) |
007_experiences.sql |
experiences, experience_pages, experience_blocks, experience_recipients, experience_contributors; four enums; private schema access helpers; RLS, column grants, validation/ordering triggers, reorder RPCs; cover storage policies (needs Postgres 15+) |
008_experience_interactions.sql |
experience_interactions table and enum; recipient RPCs (open_recipient_experience, submit_recipient_answer, record_recipient_hint, complete_recipient_experience) with server-side answer checks; storage policy so recipient links can load shared media |
009_experience_status_values.sql |
Adds expired and cancelled to experience_status. Run it on its own, before 010: Postgres can’t use a new enum value in the transaction that adds it |
010_scheduling_and_drops.sql |
Scheduling (expires_at, schedule_timezone, status state machine in prepare_experience, private.effective_status, release_due_experiences); experience_stages table and experience_pages.stage_id for Surprise Drop; recipient RPCs redefined so nothing is sent or answerable before its time |
011_contribution_block_type.sql |
Adds contributions to experience_block_type. Run it on its own, before 012 (same enum rule as 009) |
012_collaborative_contributions.sql |
experience_invitations, experience_contribution_prompts, experience_submissions; three enums; owner-only RLS and column grants; private contributions bucket with upload tickets (private.contribution_uploads); token-checked RPCs open_contribution_invitation, begin_contribution_upload, submit_contribution, recipient_contributions |
013_group_secret.sql |
experiences.group_secret (owner-only toggle, enforced by trigger); private.is_unrevealed_secret; recipient RPCs and private.recipient_access return nothing for an unrevealed secret; viewer collaborators can’t see one until it’s revealed; open_contribution_invitation gains group progress |
014_ai_infrastructure.sql |
AI infrastructure: app_admins, ai_provider_configs, ai_task_configs, ai_plans, ai_user_plans, ai_model_prices, ai_usage_events, ai_usage_daily and the ai_usage_monthly view. All have RLS with no browser policies. Service-role RPCs handle limits, usage and Vault-stored API keys. is_app_admin() and ai_available_tasks() are for signed-in users. Enables supabase_vault |
015_ai_store_key_fix.sql |
Fixes ai_store_provider_key key validation (014’s regex used a repetition count above Postgres’s limit of 255, so saving a key always failed) |
016_experience_generator.sql |
ai_available_tasks() now offers experience_generation; its default max_tokens goes from 4000 to 8000 (only if unchanged) |
017_ai_editing.sql |
personalization (used by AI rewrites in the builder) gets a default max_tokens of 3000 instead of 800 (only if unchanged) |
018_group_invite_links.sql |
Shared group invite links: experience_invitations.shared (one per experience), the group wall on open_contribution_invitation, higher limits for the group link, and contributor counts per person |
019_memory_stories.sql |
memory_stories (stories written from selected memories, owner-only RLS, generated_at stamped by trigger); story_generation gets a default max_tokens of 6000 instead of 1500 (only if unchanged) |
020_admin_and_payments.sql |
Admin console and payments: admin_audit_log (normally append-only), user_moderation, experience_moderation, experience_reports + report_experience(), template_settings, gift_products, payment_settings, coupons, experience_orders, experience_unlocks, experience_payment_status(). Recipient and contributor links now also require the experience not blocked, its owner not suspended and (while payments are on) the experience unlocked. Razorpay secrets live in Vault |
021_about_page_and_guest_create.sql |
About page and guest create: site_about (one row of JSON content, readable by everyone, written only by admin-console), the public site-assets storage bucket (admin-only uploads under about/, 5 MB, JPEG/PNG/WebP), and read access to template_settings for signed-out visitors so guest Quick Create respects hidden and featured templates |
022_short_links_and_template_expiry.sql |
Short links: a unique 7-character short_code on experience_recipients and experience_invitations (set by trigger, existing rows backfilled) and resolve_short_link(), which turns a code into its token and allows 30 misses per 15 minutes per IP. Adds expires_at and deleted_at to template_settings, and scheduled/end times to the admin experience list |
023_admin_history_purge.sql |
Admin-only, audited clearing of payment orders and audit history. Order deletion preserves existing experience unlocks; audit clearing leaves a new immutable purge record |
Local (needs Docker):
npx supabase start # prints the local API URL and anon key
npx supabase db reset # re-applies migrations + seed.sql
npm run db:types # regenerates src/lib/supabase/database.types.ts
In the Supabase Auth settings, set Site URL and add these Redirect URLs for every environment (for example http://localhost:5173/**), so the email confirmation and password reset links work.
Privileged work (AI, admin console, payments) runs in five Supabase Edge Functions. Each one imports shared code from supabase/functions/_shared/, so it must be deployed with the CLI (which uploads _shared) or as a bundle (a single self-contained file). Pasting supabase/functions/<name>/index.ts into the Dashboard editor fails with Module not found ".../_shared/env.ts".
| Function | Used by | Verify JWT |
|---|---|---|
ai-admin |
AI settings page (/admin/ai) |
On |
ai-generate |
Help me write, experience generator, AI editing, memory stories | On |
admin-console |
Admin console (/admin/...) |
On |
payments |
Checkout before sharing (quote, create order, verify) | On |
payments-webhook |
Razorpay webhook (order.paid, payment.captured) |
Off: Razorpay can’t send a Supabase token; the function checks Razorpay’s signature instead |
Secrets (Edge Functions → Secrets, or npx supabase secrets set):
| Secret | Required | Notes |
|---|---|---|
SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY |
Yes | Provided by Supabase automatically; don’t set them yourself |
ALLOWED_ORIGINS |
Yes (production) | Comma-separated app origins allowed to call the functions, e.g. https://www.unveilme.in,https://unveilme.in,http://localhost:5173. Origins must match exactly (scheme and hostname; no trailing slash). Empty allows any origin |
GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY |
No | Fallback AI keys; keys saved on the AI settings page take priority |
Razorpay keys are not function secrets: save them in Admin → Payments (stored in Vault).
No bundling needed. Find the project ref in the Dashboard URL (https://supabase.com/dashboard/project/<project-ref>) or Project Settings → General.
npx supabase login
npx supabase link --project-ref <project-ref>
npx supabase secrets set ALLOWED_ORIGINS=https://www.unveilme.in,https://unveilme.in,http://localhost:5173
# Optional fallback AI keys
npx supabase secrets set GEMINI_API_KEY=... OPENAI_API_KEY=... ANTHROPIC_API_KEY=...
npx supabase functions deploy ai-admin ai-generate admin-console payments
npx supabase functions deploy payments-webhook --no-verify-jwt
npx supabase functions list # all five should be ACTIVE
If the deploy complains that Docker isn’t running, add --use-api to the deploy commands to bundle on Supabase’s side.
Build the bundles in a terminal at the project root:
npm install
npm run bundle:functions
This writes one self-contained file per function to supabase/.bundle/: ai-admin.js, ai-generate.js, admin-console.js, payments.js and payments-webhook.js.
index.ts.supabase/.bundle/<name>.js. In VS Code: open the file, Ctrl+A, Ctrl+C. In PowerShell: Get-Content supabase\.bundle\admin-console.js -Raw | Set-Clipboard.Open payments-webhook → Details and turn Verify JWT with legacy secret / Enforce JWT verification off. Leave it on for the other four.
ALLOWED_ORIGINS (and any fallback AI keys) under Edge Functions → Secrets.supabase/functions/<name>/: redeploy that function.supabase/functions/_shared/: redeploy all five.npm run bundle:functions again first, then paste the new bundle. Bundles are build output; never edit them by hand.ALLOWED_ORIGINS are configured.http://localhost:5173, update ALLOWED_ORIGINS in the Supabase project’s Edge Functions → Secrets (or with the CLI command above). Setting it in Vercel does not change the Edge Function’s secret.Module not found ".../_shared/env.ts": you pasted the source file; paste the bundle instead (Option B).Missing environment variable ...: a required secret is absent; the three SUPABASE_* ones exist only on hosted projects or supabase start.403 from a function: add your app’s origin to ALLOWED_ORIGINS.401: JWT verification is still on for payments-webhook.001–023 in order (SQL Editor), then the scripts in supabase/verification/ if you want to confirm them (each prints PASS).ALLOWED_ORIGINS (Edge Functions).Sign up in the app, then make yourself an admin (SQL Editor):
insert into public.app_admins (user_id)
select id from auth.users where lower(email) = 'you@example.com'
on conflict (user_id) do nothing;
Reload the app; Admin console and AI settings appear in the nav.
rzp_test_… or rzp_live_…), then the Key secret, and click Test connection.https://<project-ref>.supabase.co/functions/v1/payments-webhook with the events order.paid and payment.captured, choose a webhook secret, and save the same secret in Admin → Payments.| Script | Purpose |
|---|---|
npm run dev |
Start the dev server |
npm run build |
Type-check (tsc -b) and build for production |
npm run preview |
Serve the production build |
npm run typecheck |
Type-check only |
npm run lint |
Run ESLint |
npm run db:types |
Regenerate DB types from the local database |
npm run check:functions |
Type-check the Edge Functions and their tests |
npm run test:functions |
Run the Edge Function tests (Node test runner, no network) |
npm run test:app |
Type-check and run the app’s pure-logic tests in tests/ (scenes, Quick Create, recipient playthroughs, AI editing, About, dashboard) |
npm run bundle:functions |
Bundle each Edge Function into supabase/.bundle/<name>.js for the Dashboard editor (Edge Functions) |
| Symptom | Cause and fix |
|---|---|
| App shows a configuration error on start | VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY missing, or a service-role/secret key was used. Fix .env.local and restart npm run dev. |
| Email confirmation or password reset link fails | Add the app URL (e.g. http://localhost:5173/**) to Supabase Auth Redirect URLs, and open the link in the same browser (PKCE). |
relation ... does not exist or a page shows a load error |
A migration hasn’t run. Run the missing files in order; 009 and 011 must each run on their own. |
unsafe use of new value when running 010 or 012 |
009 / 011 ran in the same transaction. Run them alone first, then the next file. |
| AI buttons don’t appear | The provider or task is off, there’s no key, or the user’s plan has no AI. Check AI settings. |
Invalid request: ... is not allowed when saving in the admin console |
The deployed Edge Function is older than the app. Rebuild bundles and redeploy (all five if _shared changed). |
CORS error or 403 from a function |
Add the app’s origin to ALLOWED_ORIGINS. |
| Admin nav doesn’t appear | Add your user to app_admins, then reload (admin status is cached for up to 10 minutes). |
| Recipient link shows “on its way” | The experience isn’t published or shared with recipients, is scheduled for later, is blocked, its owner is suspended, it’s a Group Secret before its reveal, or payment is still needed. |
| Deep links 404 in production | Configure the host’s SPA fallback to /index.html. |
| Shared links show no picture | VITE_SITE_URL wasn’t set at build time, so og:image is relative. Set it, rebuild and redeploy. WhatsApp and others cache previews for days, so test with a new URL (e.g. add ?v=2). |
| No Install app button | Already installed, not on HTTPS, a dev build (npm run dev has no service worker), or a browser that can’t install (Firefox desktop). Try npm run build && npm run preview in Chrome or Edge. |
| Installed app still shows an old version | The host caches sw.js. Serve it with Cache-Control: no-cache; the app updates on the next launch. |
See also the Edge Function checks under Checking and troubleshooting, and Admin → System health.
VITE_* variables are bundled. src/config/env.ts refuses to start if the key looks like a service-role or secret key (sb_secret_… or a JWT with role: service_role).profiles that means only full_name, username, avatar_url, bio, timezone and onboarded_at; id and the timestamps are read-only.<bucket>/<user_id>/…, storage policies check the folder owner, and the client reads files through short-lived signed URLs.flowType: 'pkce'). Email links therefore have to be opened in the same browser that started the flow.features/auth/redirect.ts) to prevent open redirects.admin-console and ai-admin re-check app_admins on every request, and every admin change or sensitive read goes to admin_audit_log. It is immutable except for the dedicated purge RPC, which is service-role-only, checks admin membership, and inserts an immutable purge record in the same transaction.ALLOWED_ORIGINS) and validate every request body against strict schemas.http(s) (and mailto: on the About page).supabase/
config.toml Local Supabase CLI config
migrations/ Numbered SQL migrations 001–023 (source of truth for schema)
verification/ SQL scripts that assert RLS and RPC behaviour (run in the SQL Editor; they roll back and print PASS)
functions/
_shared/ Shared Edge Function code: env, HTTP/CORS, auth, audit, Supabase client, AI service + adapters, payments (Razorpay)
ai-admin/ ai-generate/ AI settings and AI generation
admin-console/ Admin console actions (analytics, moderation, templates, gifts, payments, coupons, About page, audit)
payments/ payments-webhook/ Checkout (options, quote, create_order, verify) and the Razorpay webhook
tests/ Edge Function tests (Node test runner, no network)
.bundle/ Build output of `npm run bundle:functions` (never edit by hand)
seed.sql Local-only seed data (no real personal data)
tests/ App pure-logic tests (scenes, Quick Create, recipient flow, AI editing, About, dashboard…)
public/ Served as-is: favicon, app icons, manifest.webmanifest, sw.js (service worker), og-image.jpg (link previews)
src/
main.tsx Entry: validates env, then loads the app
app/
App.tsx Providers + RouterProvider
AppProviders.tsx QueryClient + Auth context
router.tsx Route tree (lazy pages, guards, layouts)
paths.ts Central URL map — always link via `paths.*`
navigation.ts Sidebar / mobile nav configuration
config/env.ts Typed, validated public env
lib/
supabase/client.ts Single typed Supabase client
supabase/database.types.ts Generated DB types (regenerate after migrations)
ai/aiClient.ts Calls to the AI Edge Functions
theme.ts, useTheme.ts Light / dark / system theme
pwa.ts, usePwaInstall.ts Service worker registration and "Install app" state
query-client.ts TanStack Query defaults
cn.ts className helper
hooks/ Cross-feature hooks (e.g. useDismiss)
styles/index.css Tailwind + design tokens (@theme), keyframes, dark palette
components/
ui/ Design-system primitives (Button, Card, Avatar, TextField, TextAreaField, SelectField, FormField, Badge, Alert, EmptyState, ConfirmDialog, Logo, Spinner)
layout/ AppShell, Sidebar, TopBar, MobileNav, MobileMoreSheet, UserMenu, NotificationsMenu, PageContainer, PageHeader, RootLayout
feedback/ Full-page / in-page loaders, configuration error screen
pwa/ Install app button, dashboard banner and Settings card
features/
auth/ Provider, guards, API, sign-in/up, forgot/reset password, auth layout
profile/ Profile API, hooks, onboarding guards, avatar/timezone pickers, image preparation
onboarding/ Post-signup onboarding wizard
landing/ Public landing page
about/ Public About page and its content model
dashboard/ Greeting, upcoming celebrations, drafts, scheduled and completed
people/ occasions/ Loved ones, celebrations, recurrence and calendar
memory-vault/ Memories, uploads, timeline and memory stories
experiences/ Data layer, blocks, renderer and player, scenes engine, template library, themes, stages
experience-builder/ Builder UI, Quick Create, AI generator, guest drafts, writing help and AI edits
recipient/ Recipient link page
contribute/ Contributor invite page
payments/ Checkout before sharing
settings/ system/ Settings, appearance and not-found / system pages
admin/ Admin console and AI settings
| Path | Access | Page |
|---|---|---|
/ |
Public | Landing: hero, occasion categories (each opens Create with ?occasion=), an animated how-it-works demo, features |
/about |
Public | About Unveil: the platform, the creators and links (edited in Admin → About page) |
/create |
Public | Guest Quick Create: try it without an account (optional ?occasion=); signed-in users are sent to /experiences/quick |
/auth/sign-in |
Guest | Sign in |
/auth/sign-up |
Guest | Sign up |
/auth/forgot-password |
Guest | Request reset link |
/auth/reset-password |
Link | Set new password (session from email) |
/onboarding |
Auth, not onboarded | Onboarding (name, username, photo, timezone) |
/dashboard |
Auth | Dashboard |
/people |
Auth | People list (search ?q=, filter ?rel=) |
/people/new |
Auth | Add a person |
/people/:personId |
Auth | Person detail |
/people/:personId/edit |
Auth | Edit a person |
/occasions |
Auth | Occasions: list or calendar (?view=calendar), filters ?type= ?person= (none = unlinked), ?when=past, ?month=YYYY-MM, ?day=YYYY-MM-DD |
/occasions/new |
Auth | Add an occasion (optional prefill ?person= ?date= ?type=) |
/occasions/:occasionId/edit |
Auth | Edit or delete an occasion |
/memory-vault |
Auth | Memory Vault timeline: filters ?type= ?person= (none = unlinked), search ?q=, ?sort=oldest, open a memory ?memory=<id> |
/memory-vault/new |
Auth | Add a memory (optional prefill ?person= ?type=) |
/memory-vault/:memoryId/edit |
Auth | Edit or delete a memory |
/memory-vault/stories |
Auth | Your memory stories |
/memory-vault/stories/new |
Auth | Write a story from ?memories=<id>,<id> (2–20) |
/memory-vault/stories/:storyId |
Auth | Read, edit, regenerate or delete a story |
/experiences |
Auth | My Experiences (your own and shared with you; owners can delete) |
/experiences/new |
Auth | Name a new experience (optional prefill ?occasion= ?person= ?template=), then open the builder |
/experiences/quick |
Auth | Quick Create: five guided steps from occasion to a published private link, no builder needed |
/experiences/templates |
Auth | Template library: filter ?category= ?tag=, preview any template, then create from it (keeps ?occasion= ?person=) |
/experiences/:experienceId/edit |
Auth | Experience Builder (full screen, outside the app shell; owners only for now) |
/experiences/:experienceId/responses |
Auth | Share & responses: add recipients, copy their personal links, see answers live (owner only) |
/experiences/:experienceId/contributions |
Auth | Contributions: invite friends, set questions, approve or reject what they send (owner only) |
/settings |
Auth | Profile settings (edit profile, sign out) |
/admin, /admin/:section |
Auth, admin | Admin console: analytics, users, experiences, reports, templates, gift products, payments & coupons, subscriptions, About page, AI usage, system health, audit log |
/admin/ai |
Auth, admin | AI settings: providers, keys, models, tasks, plans, prices, usage (others see “Admins only”) |
/x/:code |
Public | Recipient experience, opened with a recipient’s personal link (no account needed) |
/experience/:accessToken |
Public | Earlier long form of recipient links (64-hex token); still opens the same page |
/c/:code |
Public | Contributor page, opened with a friend’s invite link (no account needed) |
/contribute/:inviteToken |
Public | Earlier long form of invite links; still opens the same page |
* |
Public | Not found |
When deploying, configure the host to serve SPA fallbacks by rewriting all paths to /index.html.
“Auth” routes also require a completed onboarding (profiles.onboarded_at set). Otherwise the user is sent to /onboarding.
flowchart TD
A["/auth/sign-up"] -->|signUp with full_name + browser time zone| B["on_auth_user_created trigger<br/>creates the profiles row"]
B --> C{Email confirmation on?}
C -- Yes --> D[Confirmation email] --> E["/onboarding"]
C -- No --> E
E --> F["Name and username → photo → time zone<br/>sets onboarded_at"]
F --> G{Guest Quick Create draft waiting?}
G -- Yes --> H["/experiences/quick<br/>draft adopted and saved"]
G -- No --> I["/dashboard"]
S["/auth/sign-in"] --> J{Onboarded?}
J -- No --> E
J -- Yes --> K[Original page, Quick Create or dashboard]
P["/auth/forgot-password"] --> Q[Reset email] --> R["/auth/reset-password"] --> S
Flow
/auth/sign-up). This calls supabase.auth.signUp with full_name and the browser’s timezone as user metadata. The on_auth_user_created trigger (security definer) creates the profiles row and falls back to UTC if the timezone is invalid./onboarding. When they are off, the user goes straight there.onboarded_at, then opens /dashboard.localStorage and refreshed automatically. AuthProvider listens to onAuthStateChange, and signing out clears every cached query.Guards (src/app/router.tsx)
RedirectIfAuthenticated: signed-in users on sign-in, sign-up or forgot-password are sent to /dashboard.RequireAuth: guests are sent to /auth/sign-in and return to the original page after signing in.RequireOnboarded / RequireNotOnboarded: route users to or away from /onboarding based on the profile.public.profiles
| Column | Type | Notes |
|---|---|---|
id |
uuid PK | references auth.users(id) on delete cascade |
full_name |
text | 1–100 chars, trimmed; required once onboarded |
username |
text | optional, unique, ^[a-z0-9_]{3,30}$, lower-cased by trigger |
avatar_url |
text | object path in the private avatars bucket (<user_id>/…); the client resolves it to a 1-hour signed URL |
bio |
text | optional, ≤ 280 chars |
timezone |
text | IANA name validated against pg_timezone_names; defaults to UTC |
onboarded_at |
timestamptz | null until onboarding is finished |
created_at / updated_at |
timestamptz | updated_at is maintained by a trigger |
Access rules
select and update only where id = auth.uid().anon has no access at all.Avatars
avatars/<user_id>/avatar-<uuid>.webp, and the previous file is deleted after the profile row is saved.Username availability
23505, which is shown on the username field.supabase/migrations in numeric order in the SQL Editor.supabase/verification/profiles_rls_check.sql into the SQL Editor and run it.
authenticated role plus JWT claims), then asserts that:
id or created_at are denied;anon is denied.PASS: all profile RLS checks passed; any failure raises FAIL: ….npm run dev):
/onboarding. Complete it, then check that /dashboard greets you by name.full_name, username, timezone, avatar_url and onboarded_at./dashboard should now redirect to /auth/sign-in, and signing in should return you to the dashboard./auth/sign-in or /onboarding should redirect to /dashboard./settings, reload, and check that the changes persisted.Users keep a private list of the people they care about (src/features/people).
public.people
| Column | Type | Notes |
|---|---|---|
id |
uuid PK | generated by the client on create so the photo can be uploaded to its folder first |
user_id |
uuid | defaults to auth.uid(); not writable by clients; cascades on user delete |
name |
text | required, 1–100 chars, trimmed |
nickname |
text | optional, ≤ 60 chars |
relationship_type |
enum | partner, spouse, parent, child, sibling, best_friend, friend, colleague, teacher, relative, other |
avatar_url |
text | storage path avatars/<user_id>/people/<person_id>/…, enforced by a check constraint |
birthday |
date | optional, 1900 or later (the form also rejects future dates) |
anniversary_date |
date | optional, 1900 or later |
notes |
text | optional, ≤ 5000 chars |
favorite_things |
text[] | ≤ 50 items, each ≤ 80 chars; trimmed and de-duplicated (case-insensitive) by trigger |
created_at / updated_at |
timestamptz | updated_at maintained by trigger |
Access rules: RLS policies for select, insert, update and delete all require user_id = auth.uid(). Column grants stop clients from setting user_id or the timestamps. anon has no access.
UI
avatars bucket. The browser crops them to WebP, and the list loads their signed URLs in a single batch request.Verifying: after running 004_people.sql, run supabase/verification/people_rls_check.sql in the SQL Editor. It should return one row: PASS: all people RLS checks passed.
The days worth celebrating (src/features/occasions).
public.occasions
| Column | Type | Notes |
|---|---|---|
id |
uuid PK | gen_random_uuid() |
user_id |
uuid | defaults to auth.uid(); not writable by clients; cascades on user delete |
person_id |
uuid | optional; composite FK (person_id, user_id) → people(id, user_id), so an occasion can only link to one of your people; cascades when the person is deleted |
title |
text | required, 1–120 chars, trimmed by trigger |
occasion_type |
enum | birthday, anniversary, graduation, wedding, engagement, promotion, new_job, farewell, thank_you, mothers_day, fathers_day, valentines_day, friendship, achievement, custom |
date |
date | 1900-01-01 to 2200-12-31; for recurring occasions this is the first/original date |
recurring |
boolean | default true |
reminder_days_before |
integer | 0–365 or null (no reminder); default 7 |
notes |
text | optional, ≤ 2000 chars; blank becomes null |
created_at / updated_at |
timestamptz | updated_at maintained by trigger |
Access rules: RLS policies for select, insert, update and delete all require user_id = auth.uid(). Column grants stop clients from writing id, user_id or the timestamps. anon has no access. The composite foreign key matters because referential checks bypass RLS; without it a user could link an occasion to someone else’s person id.
Recurrence (no duplicated rows)
recurrence.ts) in the user’s profile timezone, so it appears every future year without extra rows.Profile dates: birthdays and anniversaries saved on a person appear automatically as occasions; they are not copied into the table. If you save an occasion for the same person, type and day, it replaces the profile entry in lists (useful for a custom name, reminder or notes). Editing a profile entry opens the person’s edit page.
UI
/occasions: list view (grouped by month, with days remaining) or calendar view (month grid with the locale’s first weekday, a selected-day panel and “Add on this day”). The type and person filters apply to both views and live in the URL./experiences/new?occasion=<key>&person=<id>, which suggests a title and person and links saved occasions to the new experience.Verifying: after running 005_occasions.sql, run supabase/verification/occasions_rls_check.sql in the SQL Editor. It should return one row: PASS: all occasions RLS checks passed.
A private home for photos, videos, voice notes, stories, quotes and links (src/features/memory-vault).
public.memories
| Column | Type | Notes |
|---|---|---|
id |
uuid PK | gen_random_uuid() |
user_id |
uuid | defaults to auth.uid(); not writable by clients; cascades on user delete |
person_id |
uuid | optional; composite FK (person_id, user_id) → people(id, user_id), so a memory can only link to one of your people. Deleting the person sets it to null (on delete set null (person_id), Postgres 15+), so the memory is kept |
type |
enum | photo, video, audio, text, quote, link; fixed once created |
title |
text | required, 1–120 chars, trimmed by trigger |
description |
text | optional (required for text and quote), ≤ 5000 chars; blank becomes null |
memory_date |
date | optional, 1900–2200 (the form also rejects future dates) |
location |
text | optional, ≤ 200 chars |
metadata |
jsonb | object, ≤ 4 KB. Media: storage_path, mime_type, size, width, height, duration. Quote: author. Link: url |
created_at / updated_at |
timestamptz | updated_at maintained by trigger |
Check constraints make the metadata trustworthy: photo/video/audio rows must reference <user_id>/<sha256>.<ext> inside the owner’s own folder with a MIME type matching the memory type; other types can’t carry a storage path; links must be http(s) and ≤ 2048 chars.
Access rules: RLS policies for select, insert, update and delete all require user_id = auth.uid(). Column grants stop clients from writing id, user_id, type (after insert) or the timestamps. anon has no access.
Storage (memories bucket)
preparePhoto (memory-vault/media.ts) makes it lighter for mobile networks: JPEG, PNG and WebP photos over 2048 px on the long edge or 1.5 MB are scaled to fit 2048 px and re-encoded (JPEG stays JPEG at 0.85, PNG/WebP become WebP), which also drops EXIF data such as GPS. Animated PNG/WebP, GIF and AVIF are kept as they are, and so is any photo the browser can’t make smaller.No duplicated media
memories/<user_id>/<hash>.<ext>, so the same file always gets the same path.MediaPicker, not copy files.UI
/memory-vault: a timeline grouped by year in a masonry grid. You can filter by type (with counts), by person (or “not linked”) and by search text, and sort newest or oldest first. Undated memories come last. Filters live in the URL.MediaPicker (components/MediaPicker.tsx) is a reusable dialog for choosing memories. It supports search, person and type filters, single or multiple selection, types, excludeIds, initialSelectedIds and maxSelection. It returns the chosen Memory rows. Person pages use it for Link from vault.addTypes (and optionally initialAddMode), the picker also offers Upload, Take a photo and Record a video (components/NewMediaMemory.tsx, new-media.ts). The new file goes through the same checks, fingerprinting, dedupe and private upload as the add form, and is saved as a real memory (with a title) before it is returned. If the file is already in the vault, that memory is reused.CameraCapture (components/CameraCapture.tsx) uses getUserMedia for a live preview. Photos are saved as JPEG from a canvas. Videos are recorded with MediaRecorder (MP4 where supported, otherwise WebM), up to 2 minutes and inside the 50 MB limit. Tracks stop when you leave. It needs HTTPS (or localhost) and camera/microphone permission. “Use your device’s camera app” (<input capture>) is the fallback, and on phones it opens the native camera.Verifying: after running 006_memory_vault.sql, run supabase/verification/memories_rls_check.sql in the SQL Editor. It should return one row: PASS: all memories RLS checks passed. Storage policies are checked structurally there. Check uploads by hand: add the same photo twice (the second time should say it’s reused), and delete one memory (the file should stay until the last memory using it is deleted).
On /memory-vault, Turn memories into a story (shown when the story_generation AI task is available) lets you pick 2–20 memories. You then choose a tone (heartfelt, funny, playful, emotional) and a length (short, medium, long), and the AI connects them into a titled story with an introduction, sections (each built on one or more memories, with a caption per memory) and a closing.
m1–m20 (memory-story.ts, buildStoryBrief). Ids, files, signed URLs and link addresses are never sent, and the AI can’t see media.supabase/functions/_shared/ai/memory-story.ts) forbids new events, names, people, dates, places or conversations, and asks that added language read as feeling rather than fact. Refs the model makes up are dropped, and a reply with no usable section is rejected.ai-generate accepts { story: brief } and runs story_generation with a single attempt (no fallback). The app never retries by itself. If the save fails after writing, Save my story saves the draft without another request.public.memory_stories: title (1–120), content (jsonb: generated, introduction, sections[{id, heading, text, memory_ids, captions}], conclusion, edited; ≤ 64 KB), memory_ids (2–20), tone, length, generated_at (set by trigger when the story is written or regenerated). Owner-only RLS; clients can’t write user_id, generated_at or the timestamps.
Verifying: after 019, run supabase/verification/memory_stories_check.sql. It should return PASS: all memory story checks passed.
The database architecture for interactive celebrations (src/features/experiences). The visual editor is described in Experience Builder.
Tables
| Table | Key columns | Notes |
|---|---|---|
experiences |
user_id (owner), person_id, occasion_id, title, description, status, visibility, cover_image_url, theme_id, scheduled_at, published_at |
Person and occasion use composite FKs to the owner’s own rows and are unlinked (on delete set null) when those are deleted. title 1–120, description ≤ 2000, theme_id is an app theme slug. cover_image_url is a path in the owner’s memories bucket (<user_id>/<sha256>.<ext>), not a copy |
experience_pages |
experience_id, position, title, background (jsonb) |
Ordered screens. background is an object ≤ 8 KB, e.g. {"color": …} or {"memory_id": …} (photo/video) |
experience_blocks |
page_id, type, position, content (jsonb), settings (jsonb) |
type is one of text, image, video, audio, button, divider, countdown, reveal, quote, question, quiz, puzzle, memory, message and is fixed after insert. content (≤ 64 KB) is what’s shown, settings (≤ 16 KB) is how it behaves, including expected answers |
experience_recipients |
experience_id, name, email, phone, access_token, opened_at, completed_at |
Name trimmed, email lower-cased and unique per experience, phone reduced to +digits. access_token is 64 random hex characters generated by the database; short_code is the 7-character code used in /x/<code> links |
experience_contributors |
experience_id, user_id, role |
editor or viewer; one row per user; the owner can’t be added |
Access (RLS + column grants)
| Owner | Editor | Viewer | Anyone else | |
|---|---|---|---|---|
| See the experience, pages, blocks, team | ✓ | ✓ | ✓ | – |
| Edit title, description, cover, theme | ✓ | ✓ | – | – |
| Add, edit, reorder, delete pages and blocks | ✓ | ✓ | – | – |
| Change status, schedule, visibility, person, occasion | ✓ | – | – | – |
| Delete the experience | ✓ | – | – | – |
| See and manage recipients (contact details, link tokens) | ✓ | – | – | – |
| Invite, re-role, remove contributors | ✓ | – | – | – |
| Leave | – | ✓ | ✓ | – |
SECURITY DEFINER helpers in a non-exposed private schema (owned_, shared_, viewable_, editable_experience_ids()), so tables can check each other without RLS recursion.experiences are enforced by a trigger, because owners and editors share the same database role.user_id, published_at, positions on insert, access_token, short_code, opened_at, completed_at or the timestamps. anon has no access to any of these tables.Lifecycle: new experiences start as draft. scheduled requires a future scheduled_at. Publishing sets published_at. archived hides it from active use. Migration 010 adds the full state machine and automatic opening at scheduled_at (see Scheduling and Surprise Drop).
Ordering: new pages and blocks are always appended (under an advisory lock, max 100 per experience/page). reorder_experience_pages(experience_id, page_ids[]) and reorder_experience_blocks(page_id, block_ids[]) take the complete new order and renumber positions in one statement. A stale list is rejected.
Media is referenced, never copied: blocks and page backgrounds point to Memory Vault memories by memory_id. A trigger checks that the memory belongs to the experience’s owner, and that image/video/audio blocks use a memory of that kind. A file used as a cover can’t be deleted from storage, and contributors can read covers of experiences they can see. Button links must be http(s).
TypeScript
api/experiencesApi.ts, api/contentApi.ts, api/sharingApi.ts: typed data-access functions. Field types are Picks that match the column grants.blocks.ts: BLOCK_TYPES, per-type BlockContentMap / BlockSettingsMap, newBlock(type) and readBlock(block).hooks/useExperiences.ts, hooks/useExperienceContent.ts (reordering is optimistic with rollback), hooks/useExperienceSharing.ts (including useExperienceRole).Built on top of this layer later: recipient viewing through token-checked RPCs that strip answers (008), shared media for recipients (008), scheduling and automatic opening (010), friend contributions through invite links (012) and payments (020).
Still open: inviting collaborators (editors and viewers) by email; the API takes a user id.
Verifying: after running 007_experiences.sql, run supabase/verification/experiences_rls_check.sql in the SQL Editor. It uses an owner, an editor, a viewer and a stranger, and should return one row: PASS: all experiences RLS checks passed.
A visual storytelling editor on top of experience_pages and experience_blocks (src/features/experience-builder). No new SQL is needed.
Layout
lg: the canvas fills the screen. The library and properties open as bottom sheets from a bottom bar, and each block gets an Edit button.Editing
Alt+↑/↓ moves the focused block and Delete removes it.dnd.ts), so no library is needed.MediaPicker. Blocks store only memory_id. For photo and video slots (Image, Video, Memory, Reveal and the puzzle picture), MediaField also offers Upload, Take a photo and Record a video. The file is saved to the vault first and then selected for the block.Autosave (hooks/useAutosave.ts)
One renderer for every mode (src/features/experiences/renderer)
BlockView looks up each block type in a typed registry of block components.PageSurface draws page backgrounds, and ExperiencePlayer steps through pages one at a time.mode: 'edit'), the preview ('preview') and the future recipient page ('live') all use these same components.ExperienceRuntimeContext:
submitAnswerpages, nextPage and goToPageBlocks (renderer/blocks/*, editors in experience-builder/components/BlockSettings.tsx)
Every block reads experience_blocks.content / settings defensively (renderer/values.ts), because stored JSON isn’t validated field by field. Types and defaults live in src/features/experiences/blocks.ts. In edit mode, empty blocks show a dashed placeholder; for recipients they render nothing.
| Block | content |
settings |
Notes |
|---|---|---|---|
| Text | text |
size (body, lead, title), align |
A title renders as an h2. |
| Image | memory_id, caption, alt |
fit (cover, contain), aspect (landscape, square, portrait) |
Photo from the vault. Intrinsic sizes reserve space. |
| Video | memory_id, caption |
autoplay (muted), loop |
Uses the video’s own aspect ratio, capped at 70vh. |
| Audio | memory_id, caption |
autoplay, loop |
Shows the recording’s duration. |
| Quote | text, author |
align |
|
| Button | label, url |
action (next_page, page, link), page_id, variant, full_width |
Recipients don’t see buttons that can’t go anywhere. |
| Divider | – | style (line, flourish, space) |
|
| Countdown | target_at (ISO), label, ended_text |
show_target |
Updates every second, then shows ended_text. |
| Memory | memory_id, note |
show_date |
Any memory type: photo, video, audio, story, quote or link. |
| Message | greeting, text, from |
style (letter, card, note) |
|
| Reveal | teaser, text, memory_id |
gesture (tap, hold, scratch) |
The hidden content stays inert until the recipient interacts. Scratch is a canvas cover (ScratchCard.tsx) that clears at 50%. Keyboard users get a “Reveal without scratching” button. |
Validation (src/features/experiences/validation.ts)
validateBlock returns errors (the block is empty or broken for recipients) and warnings (worth a second look). Examples: missing or deleted memories, the wrong memory type, invalid links, buttons that point at a deleted page, and countdowns in the past.Publishing: Publish sets published, and Schedule sets scheduled with a future scheduled_at in your local time. Unpublish returns the experience to draft. Only owners can change status.
Themes (src/features/experiences/themes)
themes.ts: Elegant, Romantic, Birthday, Friendship, Funny, Minimal, Celebration, Family, Graduation, Wedding and Dark cinematic. Each one defines typography (display, body and accent fonts), background, colours, card styling, border radii, button styling, spacing, a motion style, decorative elements and confetti.experiences.theme_id (autosaved); unknown or empty slugs fall back to Elegant. New experiences start in a theme suggested by their occasion (theme-suggestions.ts).PageSurface turns the theme into --x-* CSS variables (surface.ts), loads its Google Fonts on demand (fonts.ts) and draws its decorations (ThemeDecor.tsx). Blocks read the variables through the x-card, x-feature, x-photo, x-button, x-option and x-accent-font utilities in src/styles/experience.css, so every block restyles itself.Animations (themes/motion.ts, renderer/motion, renderer/CelebrationLayer.tsx)
transform and opacity.prefers-reduced-motion: Motion is set to reducedMotion="user", confetti is skipped, decorations stand still and text appears at once. The canvas in edit mode stays still.Not built yet
editor collaborators in the builder (they currently see a “coming soon” screen).AI generation, scheduling and staged unlocks have since been built: see AI editing in the builder, AI infrastructure and Scheduling and Surprise Drop.
Blocks
| Block | Sender sets | Recipient sees |
|---|---|---|
| Question | content.prompt, placeholder; optional expected settings.answer (+ case_sensitive) |
An open answer box (up to 1000 characters). Without an expected answer, any reply is accepted. |
| Quiz | content.question, options; optional settings.correct_index and explanation |
The choices. A wrong pick shakes and can be retried; the right one gets a success badge and the explanation. |
| Puzzle | content.prompt (clue), optional hint and picture clue; settings.solution (required) and optional success_message |
A guess box, a hint button, and a solved panel with the success message. |
Correct answers get a spring-in badge with a drawn tick, a ring pulse and (for puzzles) confetti; wrong answers shake. All of it honours prefers-reduced-motion.
Recipient flow
/x/<code>. Adding the first recipient switches visibility to recipients.anon has no table access. open_recipient_experience returns the pages with expected answers, explanations and success messages stripped out, plus the recipient’s earlier progress. submit_recipient_answer checks the answer on the server and only then returns the feedback.What’s stored (experience_interactions: id, experience_id, page_id, block_id, recipient_id, interaction_type, response, created_at)
Responses: the Share page lists every question, quiz and puzzle in page order with each recipient’s latest answer, tries, and hint use. Each person is labelled In progress or Finished, and the page refreshes every 20 seconds while it’s open.
Verifying: after running 008_experience_interactions.sql, run supabase/verification/interactions_rls_check.sql in the SQL Editor. It should return one row: PASS: all interactions checks passed.
Not built yet: word puzzles, and email/SMS delivery of links. (AI puzzle and quiz generation are in AI editing in the builder; the photo jigsaw is an interactive scene.)
Recipients open /x/<code> without signing in. The code is 7 letters and digits (about 3.5 trillion possibilities), generated by the database and unique across recipient and invite links. The page first calls resolve_short_link('recipient', code) to get the 64-hex access token and then works with the token as before. Anything that isn’t a valid code or token shows the “on its way” screen without calling the server. After 30 unknown codes in 15 minutes from one IP address the resolver refuses further lookups (HTTP 429) and the page says to try again later. Older /experience/<64-hex token> links still work.
sequenceDiagram
actor R as Recipient
participant P as Recipient page
participant DB as Postgres RPCs
participant S as Storage
R->>P: Opens /x/code
P-->>R: Welcome screen (nothing fetched yet)
R->>P: Open your surprise
P->>DB: open_recipient_experience(token)
DB->>DB: Check token, effective status, stage unlocks,<br/>moderation, owner suspension, payment unlock
DB-->>P: Unlocked pages and blocks (answers stripped), sets opened_at
P->>S: Signed URLs for shared media
P-->>R: Gift unwraps, story plays
R->>P: Answers a question or asks for a hint
P->>DB: submit_recipient_answer / record_recipient_hint
DB-->>P: Correct or not, plus earned feedback
R->>P: Reaches the end
P->>DB: complete_recipient_experience(token)
DB-->>P: completed_at recorded
opened_at isn’t set until they tap it, so link previews and prefetchers don’t count as a visit. The tap also lets audio and video play with sound.ExperiencePlayer in live mode renders the pages one after another, with page transitions, the theme, and every block (reveals, video, audio, countdowns, questions, quizzes, puzzles). There are no editing controls. Answers are checked on the server, earlier progress is restored, and reaching the last page sets completed_at.anon has no table access, and answers, explanations and success messages never reach the browser before they’re earned. Media uses short-lived signed URLs. The page adds noindex, nofollow, and the app sends strict-origin-when-cross-origin referrers, so the token doesn’t leak to linked sites.Recipient playthroughs (tests/recipient-flow.test.ts) build one template per major category (birthday, romantic, friendship, anniversary, graduation, thank-you, farewell) as a creator would, strip the settings the way open_recipient_experience does, and walk every route (every answer of branching questions). They check each route opens with a welcome, passes the expected scenes and interactions, can be completed without the hidden answers, ends in a replayable finale, and resumes after a refresh.
Sharing (sender side): after Publish or Schedule in the builder, an “It’s live!” (or “It’s scheduled!”) dialog shows each recipient’s private link with Copy link and Share. If there’s no one yet, it asks who it’s for (pre-filled from the person) and creates their link, turning recipient links on if the experience was private. It also explains that anyone with a link can open it, that opening it yourself counts as their visit (use Preview), and that edits keep showing up after publishing. The Dashboard shows Drafts, Scheduled surprises and Recently completed (who finished what); each recipient’s progress and Copy link / Share buttons are on the Share & responses page. Share uses the device share sheet where there is one. The same buttons are on the Share & responses page. The owner can’t open a recipient’s link from the app, because opening it counts as the recipient’s visit.
Status state machine (enforced by the prepare_experience trigger, not the browser):
stateDiagram-v2
[*] --> draft
draft --> scheduled: Schedule
draft --> published: Publish now
draft --> archived
scheduled --> scheduled: Change time
scheduled --> published: Publish now
scheduled --> cancelled: Cancel delivery
scheduled --> draft
scheduled --> archived
published --> expired: Close now or expires_at passes
published --> draft: Unpublish
published --> archived
expired --> draft
expired --> scheduled
expired --> published
expired --> archived
cancelled --> draft
cancelled --> scheduled
cancelled --> published
cancelled --> archived
archived --> draft
note right of scheduled
Recipients see it as published once
scheduled_at passes (effective_status)
end note
| From | Allowed to |
|---|---|
draft |
scheduled, published, archived |
scheduled |
scheduled (edit time), published (publish now), cancelled, draft, archived |
published |
expired (close now), draft (unpublish), archived; end time can be edited |
expired / cancelled |
draft, scheduled, published, archived |
archived |
draft |
Ending an experience: on My Experiences, each scheduled or live experience has Set expiry (the calendar icon): pick an end time, clear it, or (when live) End it now. Each card also has Delete. Admins can do the same for any experience in Admin → Experiences (see Admin console and payments).
scheduled_at and expires_at must be in the future when set. schedule_timezone must be a real IANA zone and is only used to show the time the sender picked.private.effective_status() treats a scheduled experience as published only once scheduled_at has passed (and a published one as expired once expires_at passes). Every recipient RPC, the memory access helpers and the storage policy use it, so no cron job is needed for correctness. The first recipient visit records the change; release_due_experiences() (service role only) does it in bulk. An optional pg_cron snippet is in the 010 comments.Surprise Drop: Drop in the builder header adds up to 9 later stages after Stage 1, each with an optional title and an unlock date and time. The last of two or more is called “Final surprise”. Each page belongs to a stage: pick it in Page settings, or tap a stage chip in the page tabs. New pages join the stage you’re on. Removing a stage moves its pages to the stage before it.
Verifying: after running 009 and then 010, run supabase/verification/scheduling_check.sql. It should return PASS: all scheduling checks passed.
Not built yet: email and SMS delivery at the scheduled time. Links are still shared by the sender.
A creator can invite friends to add to an experience. Open Friends in the builder header (or Contributions on the Share & responses page).
sequenceDiagram
actor O as Creator
actor F as Friend
participant App as Unveil app
participant DB as Postgres RPCs
participant S as contributions bucket
actor R as Recipient
O->>App: Create personal or group invite link, set questions
App->>DB: Insert experience_invitations (owner-only RLS)
O-->>F: Shares /c/code
F->>App: Opens the link (no account)
App->>DB: open_contribution_invitation(token)
DB-->>App: Prompts, own submissions, group wall
F->>App: Adds a photo or voice note
App->>DB: begin_contribution_upload(token)
DB-->>App: Single-use upload path
App->>S: Upload the file
App->>DB: submit_contribution(token, ...)
DB-->>App: Saved as pending
O->>App: Approve on the Contributions page
R->>DB: recipient_contributions(token) when the page opens
DB-->>R: Approved items only
/c/<7-character code>, resolved to the invite token by resolve_short_link('invitation', code); older /contribute/<64-hex token> links still work) with a name, an optional email (for the creator’s records only; nothing is emailed) and a closing date (7 to 90 days, up to a year). A link can be turned off and back on, reopened after it expires, or deleted. Deleting it also deletes everything that friend sent, files included.begin_contribution_upload, and submit_contribution checks that the file exists and matches the chosen kind. Media is read through signed URLs: by the owner, by the contributor only until the upload is used, and by recipients only for approved items of a live, shared experience.Verifying: after running 011 and then 012, run supabase/verification/contributions_check.sql. It should return PASS: all contribution checks passed.
Besides personal links, the creator can make one group link per experience (the Group link card above Invite friends) and post it in a group chat.
localStorage, unveil:group-link:<token prefix>).shared is set only when the link is created.Verifying: after 018, run supabase/verification/group_link_check.sql. It should return PASS: all group link checks passed.
Not built yet: automatic AI compilation of contributions, and emailing invitations.
A collaborative surprise that stays hidden from the recipient until a reveal time, for example “Rahul’s 30th Birthday”. Turn on Group Secret when creating the experience (or later from the Contributions page). The builder header’s Secret button opens the dashboard.
private.effective_status). Contributions stay open afterwards.private.is_unrevealed_secret is true for a secret whose effective status isn’t published or expired.open_recipient_experience returns null for an unrevealed secret before recording opened_at, and private.recipient_access (which the response, reaction and contribution RPCs use) excludes it. So nothing can be opened, answered or read.private.shared_experience_ids and private.viewable_experience_ids only include viewer collaborators once it’s revealed, which hides the experience, its pages, blocks, team and invitations under RLS.group_secret, so an editor can’t expose it early. Publishing is already owner-only.Verifying: after 013, run supabase/verification/group_secret_check.sql. It should return PASS: all group secret checks passed.
Any page can become a scene: one step of an interactive story. As soon as one page of an experience is a scene, the experience plays as a story, one scene at a time. Experiences without scenes use the original page player, unchanged.
experience_pages.background.scene (the same jsonb column as the page colour, within its 8 KB limit). open_recipient_experience already returns the whole background. The scene’s content is ordinary blocks, and answers go through the existing answer RPCs.scene.interaction.messages (index-aligned with the balloons, at most 12 of 140 characters). The builder offers an example for each balloon (Use examples) and, when AI is available to the user, a sparkle button per balloon plus Write the empty ones with AI. Stars and wheel slices use the same messages and editor (SceneMessagesEditor), with wishes and prizes as examples.pinata): a star piñata on a rope. Each tap whacks it (it swings and cracks, with a “Bam!”); after the count of whacks (1 to 12) it bursts into sweets and the scene’s blocks appear.wheel): 2 to 8 slices, each a prize message. Every spin lands on a slice not yet won, so each prize comes up once; the scene opens when all are won. A blank slice says “Surprise!”. Geometry is in renderer/scenes/interactions/wheel.ts.stars): 3 to 12 stars around a heart in a night sky. They tap each glowing star in turn; lines draw between them, each star shows its wish, and the heart closes with a shooting star (constellation.ts).lock): one dial per digit of scene.interaction.code (2 to 6 digits, stored as digits only). Put the clue in the subtitle. A wrong try shakes the lock and says how many digits are right; Hint sets one dial after the first try and Open it for me appears after three. Without a code it’s shown open. Rules are in lock.ts.jigsaw): the scene’s first photo is cut into 2 × 2, 3 × 3 or 4 × 4 pieces (the count), shown whole, then shuffled. Tap two pieces to swap them. A reference thumbnail, Hint (always places a piece) and Solve it for me mean nobody gets stuck, and the scene’s blocks (the photo and a wish) appear once it’s whole. The creator uploads or takes the photo in a photo block. Rules are in renderer/scenes/interactions/jigsaw.ts.renderer/scenes/palette.ts).src/features/experiences/scenes/config.ts.renderer/scenes/SceneView.tsx, a registry of layouts per type with an error boundary per scene.renderer/scenes/interactions/.renderer/scenes/StoryPlayer.tsx. ExperiencePlayer picks the story player or the page player.bow-aim.ts). Aim is generous, a tap or key press always hits, and after one miss the next shot can’t miss.localStorage as unveil:sound). Effects (pop, whoosh, chime, puff, open, tick, thud) are synthesized with the Web Audio API in renderer/sound.ts; there are no audio files.auto picks one from the scene type and interaction (sceneMusic in scenes/config.ts), or choose party, playful, romantic, tender, dreamy, nostalgic, mystery, adventure, celebration, calm or none. The music is generated on the device (renderer/music.ts), follows the Sound toggle, is silent in the builder, and auto stays quiet on scenes with their own audio or video.scenes/stickers.ts).yesno): a question where No runs away, shrinks and pleads each time they go for it (the plea lines are the scene’s messages), until only Yes is left. Yes sets off a celebration, then the scene opens.door): knock on the door 1 to 5 times; it swings open onto a surprise.tree): a tree grows, 3 to 12 heart buds appear, and each one tapped blooms with a planned activity (“Picnic by the lake”). The full tree showers hearts.agreement): an agreement with 1 to 8 promises. They scribble a signature (or tap to sign), agree, and a red APPROVED stamp slams down.settings.feedback, index-aligned with the options, edited under each answer). It’s shown when that answer is picked. When a scene’s answers are optional, each question card has Skip this question, so the next one comes straight in.localStorage under unveil:story:<first page id>. Only page ids, finished scenes and chosen option numbers are stored: no content, answers or creator data.
npm run test:app covers parsing and storage, routing, validation, saved progress and templates.Ready-made interactive stories, played by the scene engine above. A template is configuration, not a page: an ordered list of scenes (type, settings, blocks and links) with {token} placeholders. There is no per-template code or route, and no migration.
src/features/experiences/scenes/templates.ts defines the model and helpers. The templates themselves are in scenes/library/<category>.ts: 31 templates across birthday, romantic, anniversary, best friend, family, graduation, thank you, farewell, apology and just because. The original Birthday story and Love note are kept with the same ids.id, a version, a title, a category, a description, a thumbnail icon, a theme, minutes, fields and scenes. Tags (for example quiz, hunt, scratch or branching) and the interaction count are derived from the scenes, so they can’t drift.sample for previews and an optional fallback. Scene text uses {key}. Every token must be a field and every field must be used: the tests check both. When the experience is created, blank optional fields fall back gently (the sender becomes “Someone special”). Required fields are asked for on New experience.scenes/sample-art.ts, SVGs drawn in code) appear only in previews. Photo scenes wait for the creator’s own memories, and sample ids are never saved./experiences/templates, linked from My Experiences and New experience): filter by occasion and interaction, preview the full story with realistic sample content (experience-builder/template-preview.ts turns a template into player pages without touching the database), then Create or Use this template.applyStoryTemplate in experiences/api/templatesApi.ts) copies the scenes into ordinary pages and blocks of the new experience before the builder opens. The copy is independent: editing it never changes the template, and later template changes never change existing experiences.background.template = { id, version }, so you can tell which template and version it came from. Bump version when you change a template’s scenes. Existing and published experiences are unaffected because they hold their own copy.Creators fill in the recipient, sender, greeting, wish, four balloon messages, cake message, letter, puzzle message, surprise and final wish. Balloon message fields (suggest: 'feeling') offer Use the example and Help me write (see Help me write). The puzzle photo is chosen in the builder (upload or take one). They can then change colours, interactions (for example the chest to a gift), photos and questions in the builder, where everything autosaves.
birthday-gift-hunt, version 2): a secret-agent mission. A briefing envelope, a riddle, a present hunt with decoys, a scrambled code word and three silly mystery boxes, then a combination lock on the vault. The creator sets the code ({code}, for example their birthday as DDMM) and its clue; inside is a scratch-card golden ticket.birthday-memory-journey, version 2): photos, a timeline, then a night sky where they connect the stars into a heart, each star holding a wish (suggest: 'wish'). A letter, a look at the year ahead with playful replies, and a shooting star to hold for the final wish.birthday-quiz-party, version 2): “Welcome to The {recipient} Show!”, a warm-up round, a lightning round with cheeky replies, an emoji bonus riddle and a confetti score, then the prize wheel: they spin to win four real treats from the sender (suggest: 'prize'). A party popper finale.birthday-story, version 2): a confetti moment, a piñata to whack until it bursts into candy cards with messages, a party photo booth, a party-favour pick, a letter and a present to unwrap. Its words are now fields.experience-builder/ai-lines.ts now only holds the ready-made balloon, star and prize lines.knock-knock-love): knock on the door, a tree of hearts holding four planned dates, pick a favourite, a sideways timeline, “Will you be my Valentine?” where No runs away, and a final envelope.be-my-bestie): a yes-or-no friendship question, questions, photos, a timeline, a bestie agreement with an APPROVED stamp, and the group’s contributions.pretty-please-forgive-me): the sorry cat, a message, “Forgive me?” with a pleading No, and promises to sign. Gentle like the other apology: no timers or confetti.plea, plan and promise give their fields ready-made examples and Help me write.library/*.ts file using the helpers in library/helpers.ts (scene, say, note, photo, quiz, riddle, reveal, and so on), then run npm run test:app. The tests check fields and tokens, links, storage size, validation and a minimum of two interactions.A guided way to make an experience without the builder (/experiences/quick, linked from the Dashboard, My Experiences and New experience). It is a front end for the template library: the result is an ordinary experience with ordinary pages and blocks, played by the same recipient renderer. No migration.
flowchart LR
subgraph Guest["/create (no account)"]
G1[Who it's for] --> G2[Experience] --> G3[Personalize] --> G4[Preview]
G4 --> G5{"Sign up free to save & share<br/>or I have an account"}
end
subgraph Account["/experiences/quick"]
A1[Who it's for] --> A2[Experience] --> A3[Personalize] --> A4[Preview] --> A5[Publish]
end
G5 -->|"guest draft adopted<br/>after sign-in / onboarding"| A5
A3 -->|Customize more| B[Experience Builder]
B -->|Back to Quick Create| A3
A5 --> C{Payment required?}
C -- Yes --> D[Checkout] --> E[Private link + Copy / Share]
C -- No --> E
TemplateFields), the opening greeting and final title, photos and captions (MediaField: vault, upload or camera), optional rewording of questions and quiz answers, the theme (ThemePanel) and a colour preset. Every box shows what’s used if left blank. Customize more saves a draft and opens the builder.ExperiencePlayer exactly as the recipient sees it, with phone and desktop frames, a scene picker, restart and full screen. Missing required fields are listed with Fix buttons.recipients, publishes with no end time and shows the /x/<code> link with Copy and Share, a note that anyone with the private link can open it, and that opening it yourself counts as their visit.How it saves (experience-builder/hooks/useQuickCreate.ts, logic in experiences/scenes/quick-create.ts):
templateId/sceneKey/blockIndex) is written to localStorage under unveil:quick-create:<user id> after every change, and sanitised when read back. A refresh or a dropped connection loses nothing.syncQuickScenes in templatesApi.ts) only when they would change (signatures of the details and of the built scenes). Saves run one at a time; failures show Retry and keep the local copy.experiences/scenes/quick-sync.ts). Quick Create remembers which page and block each template scene became and what it last wrote there. Saving again is a three-way merge: it only changes what the creator hasn’t changed in the builder, keeps pages added there, and never replaces pages. Choosing a different template after customizing asks first. The builder offers Back to Quick Create for this device’s Quick Create draft.buildQuick builds the template with the creator’s values and edits. Blocks still showing a sample photo are left out, and so are scenes that can’t work without one; links and branches skip past them. Pages are stamped with the template id and version as usual.hideText).Without an account (/create, GuestCreatePage): visitors can do the first four steps (who, experience, personalize, preview) with no sign-up.
localStorage under unveil:quick-create:guest (experience-builder/guest.ts). Nothing is sent to the server./experiences/quick. AI writing help isn’t available to guests; the ready-made ideas still work.adoptGuestDraft drops any saved state and photo choices), is saved as a draft experience straight away, and the guest copy is removed. It opens on the step they left (usually Publish, with payment first when it’s required). A banner says it was brought over.unveil:quick-create:resume, so finishing onboarding (also when opened from the confirmation email) returns to Quick Create. Otherwise sign-in and onboarding go to /dashboard as usual.?occasion=<category> presets the occasion on a fresh draft (both signed in and out); the landing page’s category cards use it.Tests: tests/quick-create.test.ts checks draft parsing, that every template builds a complete story without photos, photo scenes, greeting, finale and colours, question edits, scratch-card photos, Surprise me, what’s missing, signatures and carrying a guest draft over.
Optional writing help in Quick Create and the builder. AI is never required: every field can be typed, every helper offers ready-made ideas that need no AI, and without AI (no provider, task off, limit reached or an error) the AI button is replaced by a note and everything else keeps working.
fieldWriting), balloon, star and prize lines, the greeting and final reveal, photo captions and notes, and question and quiz wording.{ writing: { kind, occasion, tone, … } } to the existing ai-generate function. The brief holds fixed choices plus at most the recipient’s name, the relationship, the template title and up to 500 characters the creator typed. Photos, media, notes about people and anything else never leave the browser._shared/ai/writing.ts), writes the instructions itself from the choices, and passes the free text only as delimited material. Replies are parsed against the exact shape and limits asked for; anything else is dropped.text_generation, letters message_generation, questions quiz_generation. Plans, per-task limits, usage logging, fallbacks and errors are the existing ones.{ task, prompt } requests are now admin-only (used by the task test on the AI settings page); everyone else gets 403.experience-builder/writing-help.ts (briefs, reading replies, inserting text, ready-made ideas), writing-samples.ts, writing-context.ts (the recipient, relationship, occasion and template the helpers may mention), hooks/useWritingHelp.ts and components/writing/.tests/writing-help.test.ts and supabase/tests/writing.test.ts (brief validation, prompt separation, reply parsing, the service call, and that the app’s choices match the server’s).ai-generate, then enable the tasks you want in AI settings.When a block is selected, Edit with AI at the top of its settings lists the actions that block offers and that the creator’s plan allows. It’s hidden when AI is off.
| Block | Actions | Field written |
|---|---|---|
| Text, Message | Improve writing, Make funnier, Make more emotional, Make shorter, Make more romantic, Create message | content.text |
| Reveal | the rewrites, Create reveal | content.text (Create reveal also writes teaser) |
| Image, Video | the rewrites, Create caption | content.caption |
| Memory | the rewrites, Create caption | content.note |
| Question | the rewrites | content.prompt |
| Quiz | Generate quiz | question, options, settings.correct_index, settings.explanation (clears feedback) |
| Puzzle | Generate puzzle | prompt, hint, settings.kind, settings.solution |
Rewrites appear only once the field has text, and not when it’s over 3000 characters.
buildEditBrief in experience-builder/ai-edit.ts). A request sends { edit: { action, occasion, tone?, field?, text?, maxChars, recipient?, relationship?, details?, memory? } }:
text is only the selected block’s field, and only for rewrites.memory is the block’s own memory, as kind, title, description and date, and only for captions, notes and reveals._shared/ai/editing.ts): it writes the instructions, passes the creator’s words only as delimited material, and returns one result, checked strictly:
personalization;message_generation;text_generation;puzzle_generation;quiz_generation.experience-builder/ai-edit.ts, hooks/useAiEdit.ts, components/writing/AiEditActions.tsx, runEdit in lib/ai/aiClient.ts.tests/ai-edit.test.ts and supabase/tests/editing.test.ts, plus the handler and service tests.017_ai_editing.sql, then bundle and redeploy ai-generate, and enable the tasks you want in AI settings.This is a provider-agnostic foundation for AI features. AI is optional: with every provider and task off (the default), the app works exactly as before.
AI Experience Generator: /experiences/generate (linked from New experience when experience_generation is available) is a wizard: person → occasion → relationship → tone (up to two) → memories from the Memory Vault (up to 12) → length and look → optional notes → generate.
buildGeneratorBrief in features/experience-builder/experience-generator.ts).{ experience: brief } to ai-generate, task experience_generation, its configured provider and model). There’s no free-form prompt for this task, not even for admins. No automatic retries; a short cooldown between attempts._shared/ai/experience.ts) checks each page and block against a strict schema, drops invented or wrong-kind memory ids and unknown blocks, fixes pages that can’t play as their type, and cuts over-long stories while keeping the ending. The app reads the result again as untrusted data before creating anything.Flow: browser → ai-generate Edge Function → AiService → provider adapter → provider API. The browser never calls a provider and never receives a key.
sequenceDiagram
participant B as Browser (aiClient)
participant F as ai-generate
participant DB as Postgres (service role)
participant V as Vault
participant P as AI provider
B->>F: Task + input, with the user's JWT
F->>F: Verify the user
F->>DB: Plan allows AI? Task enabled? Per-user task limits?
F->>DB: Provider enabled with a key?
F->>DB: ai_begin_request (daily and monthly limits, atomic)
F->>V: Read the provider key
F->>P: Request (timeout, no redirects)
alt Provider fails
F->>P: At most one fallback provider/model
end
P-->>F: Reply
F->>F: Validate against a strict schema (no HTML or scripts)
F->>DB: Record ai_usage_events (tokens, latency, cost)
F-->>B: Validated result or a safe error code
B->>B: Parse again as untrusted data before saving
supabase/functions/_shared/ai/adapters/. Each implements ProviderAdapter (generate, testConnection) using plain fetch, a timeout, no redirects and normalised error codes._shared/ai/service.ts):
generateText, generateStructuredContent and generateExperience._shared/ai/schemas.ts). Unknown keys, HTML or scripts are rejected.ai_begin_request, run atomically) → call. Every attempt is recorded in ai_usage_events with its tokens, latency, error code and estimated cost. ai_usage_daily rolls these up, and ai_usage_monthly sums them by month. Days and months are UTC./admin/ai, features/admin/):
ai-admin function, which re-checks app_admins on each request.src/lib/ai/aiClient.ts (runWriting for Help me write, runExperienceGenerator, admin-only runAiTask, invokeEdgeFunction) and useAiTasks(), which lists the tasks the signed-in user can run so AI buttons can be hidden when they can’t.Setup (after running 014, 015 and 016): deploy ai-admin and ai-generate and set the secrets as described in Edge Functions (CLI or bundled files in the Dashboard editor).
-- Add admins (SQL Editor). Each email must already have an account; unknown emails and existing admins are skipped.
insert into public.app_admins (user_id)
select id from auth.users
where lower(email) in ('alice@example.com', 'bob@example.com')
on conflict (user_id) do nothing;
-- List admins
select u.email, a.created_at
from public.app_admins a
join auth.users u on u.id = a.user_id
order by u.email;
-- Remove an admin
delete from public.app_admins
where user_id in (select id from auth.users where lower(email) = 'bob@example.com');
-- Optional: prune raw usage events nightly (keeps 90 days; daily totals are kept)
select cron.schedule('ai-prune', '17 3 * * *', $$select public.ai_prune_usage_events()$$);
Admin status is cached in the browser for up to 10 minutes, so reload the app after adding or removing an admin.
Then open AI settings:
Users without a row in ai_user_plans are on the free plan.
Security notes:
ai-admin, stored encrypted in Supabase Vault, and never returned or logged. The page only knows configured and whether the key comes from Vault or an environment secret.ai_store_provider_key. Keep log_statement at its default (none), or use environment secrets instead.Adding a provider:
_shared/ai/adapters/._shared/ai/registry.ts.supabase/tests/adapters.test.ts.Verifying:
014, 015 and 016, run supabase/verification/ai_infrastructure_check.sql. It should return PASS: all AI infrastructure checks passed.npm run check:functions and npm run test:functions type-check and test the functions: adapters, schemas, service fallback and limits, handlers and admin authorisation.admin-console Edge Function, which re-checks app_admins for the caller’s JWT on each call. Admin tables have RLS with no browser policies; the client-side useIsAdmin() only decides what to render.admin_audit_log. Entries are immutable; the explicit admin purge action is the only deletion path and leaves a new immutable record with the admin and number of entries cleared.payments-webhook), a 100% coupon, publishing while payments are off, an admin grant, or having been published before 020.sequenceDiagram
actor O as Creator
participant App as Unveil app
participant PF as payments
participant RZ as Razorpay
participant WH as payments-webhook
participant DB as Postgres
O->>App: Publish (payments on, price above zero)
App->>PF: quote(experience, gifts, coupon)
PF->>DB: Price, gift products, coupon rules
PF-->>App: Amount computed on the server
App->>PF: create_order(experience, gifts, coupon)
alt Amount is zero (100% coupon)
PF->>DB: Free order, payments_complete_order (unlock)
else Amount above zero
PF->>RZ: Create order (key secret from Vault)
RZ-->>PF: Razorpay order id
PF-->>App: Checkout details (order, key id, amount)
App->>RZ: Razorpay Checkout
O->>RZ: Pays
RZ-->>App: payment_id + signature
App->>PF: verify(order, payment_id, signature)
PF->>PF: Check the HMAC signature
PF->>RZ: Fetch the payment, match order and amount, capture if needed
PF->>DB: payments_complete_order (unlock)
RZ-)WH: order.paid / payment.captured (backup)
WH->>WH: Check the webhook signature
WH->>DB: Same unlock (idempotent)
end
App-->>O: Recipient links now open
Setup (after running 020 and supabase/verification/admin_payments_check.sql): deploy admin-console, payments and payments-webhook (JWT verification off), and redeploy ai-admin and ai-generate because _shared changed. See Edge Functions for the CLI and Dashboard-editor steps, and step 5 of the first-time setup checklist for Razorpay.
Short links, end times and deletes: run 022_short_links_and_template_expiry.sql before deploying the frontend (the app reads short_code), then bundle and redeploy admin-console.
/about shows the platform’s story (default text in features/about/about-content.ts) and, once named, one card per creator (photo, role, multi-paragraph bio), plus links and a contact email.
creatorBioIdeas). Each can be edited before Use this; existing text is only replaced or added to after asking.admin-console (about_save), which checks the admin, validates every field strictly (lengths, no markup, links must be https:// or mailto:, photos must be about/ paths, frames in range) and writes admin_audit_log (about_page_updated).site-assets bucket (admins only). The admin picks a frame (Whole photo, Square, Portrait 4:5 or Circle), then drags the photo or uses the zoom and position sliders; the page shows exactly that framing (photo_frame: shape, x, y, zoom, rendered with object-position and scale). Show photo (per creator, on by default) hides the photo or initials on the public page without deleting it, and the bio then uses the full width. Photos no longer used are deleted after a successful save. Content saved with the older single creator still loads (a blank role or bio gets the built-in one).021_about_page_and_guest_create.sql, then bundle and redeploy admin-console. Until 021 runs, /about shows the default text and the admin section shows an error. If saving shows Invalid request: content.creators is not allowed, the deployed admin-console is older than the app: redeploy it.sequenceDiagram
actor A as Admin
participant F as About page form
participant ST as site-assets bucket
participant AC as admin-console
participant DB as site_about + admin_audit_log
actor V as Visitor
A->>F: Edit text, creators, photos, frames, Show photo
F->>F: Resize photos to 1200 px WebP
F->>ST: Upload new photos under about/ (admins only)
F->>AC: about_save(content)
AC->>AC: Check admin, validate every field strictly
AC->>DB: Save content, write about_page_updated
alt Save fails
F->>ST: Remove the photos just uploaded
else Saved
F->>ST: Remove photos no longer used
end
V->>DB: /about reads site_about (public)
DB-->>V: Content, falling back to the default text
Unveil is a Progressive Web App: people can install it on Android, iPhone, iPad, Windows, macOS, Linux and ChromeOS, and it then opens from its own icon in a standalone window, with no app store.
What makes it installable
| File | Role |
|---|---|
public/manifest.webmanifest |
Name, colours, start_url /dashboard, standalone display, icons (192, 512 and a maskable 512) and shortcuts (Create a surprise, Memory Vault, Occasions). |
public/sw.js |
Service worker. Page loads are network-first, with the last good index.html as an offline fallback; hashed files in /assets/ are cache-first. It never handles other origins, so Supabase data, files and fonts always come from the network. Bump VERSION to drop old caches. |
src/lib/pwa.ts |
Registers the service worker (production builds only), captures the browser’s beforeinstallprompt, and works out the install mode. |
index.html |
Links the manifest, apple-touch-icon and the iOS web-app meta tags. |
How the Install app button behaves
| Browser | Mode | What happens |
|---|---|---|
| Chrome, Edge, Samsung Internet, Opera (Android and desktop) | prompt |
The browser’s own install dialog opens. |
| Safari and other browsers on iPhone / iPad | ios |
A dialog shows the steps: Share → Add to Home Screen → Add. |
| Safari on macOS | safari |
A dialog shows File → Add to Dock. |
| Already running as the installed app | installed |
Install UI is hidden; Settings says it’s installed. |
| Firefox desktop and others | unsupported |
Install UI is hidden; Settings explains which browsers to use. |
Where it appears:
localStorage unveil:install-dismissed).There’s no install UI on recipient or contributor links, so the surprise stays uninterrupted.
Link previews. index.html carries Open Graph and Twitter card tags, so WhatsApp, iMessage, Telegram, Slack, Discord, X, LinkedIn and Facebook show the Unveil logo, title and public/og-image.jpg (1200×630) when a link is shared.
index.html uses a __SITE_URL__ placeholder that vite.config.ts replaces with VITE_SITE_URL at build time; without it the URLs stay relative and most apps show no picture.VERSION in sw.js.The look is warm, emotional and premium, not a generic SaaS dashboard.
<em> gets an ember accent) and Plus Jakarta Sans (UI and body text).@theme tokens → Tailwind utilities):
canvas / surface: warm ivory backgroundsink-*: warm brown-black text scaleember-*: primary rose-coralsaffron-*: moments of delightplum-*: depth and immersive screenssage-*: calm confirmationrounded-card (1.75rem), pill buttons, shadow-soft, shadow-lifted, shadow-glow.animate-fade-up, animate-fade-in, animate-float, animate-pop-in, animate-shimmer, animate-drift (floating decorations on the sign-in pages). Entrances use ease-gentle; loops use ease-in-out or linear. Motion is disabled when the user has prefers-reduced-motion set.bg-aurora (soft radial warmth) and bg-ember-gradient.Users pick Light, Dark or System from the account menu, Settings → Appearance, or the landing page header. The choice is saved in localStorage (unveil-theme) and syncs across tabs. System follows the device and updates live.
.dark on <html> swaps the palette variables in src/styles/index.css, so all ink-* and accent utilities follow automatically. An inline script in index.html applies the theme before first paint. src/lib/theme.ts owns the state, and useTheme() in src/lib/useTheme.ts reads it.ink-900 becomes the lightest text). Accent tints (50–300) darken and deep shades (600–900) lighten. Solid accents (ember-400/500, saffron-400/500, plum-400, sage-500) stay the same, so white text on them keeps working.night is always dark. Use it for media letterboxing, scrims and dialog backdrops, never ink-900.elevated is a surface raised above surface (white in light mode).bg-ink-900 with text-canvas, not text-white.data-palette="fixed": re-applies the light palette to an element and everything inside it. It’s used on experience surfaces (PageSurface), artwork (auth panel, memory gradients, the plum Card) and avatar initials. Keep its values in sync with @theme./x/:code and /experience/:token always render light (useLightThemeOnly()), because every experience brings its own theme.Guidelines: use generous spacing (px-5 sm:px-8 lg:px-12, py-8 lg:py-12) and mobile-first classes. Build pages from PageContainer + PageHeader, and use EmptyState for empty and placeholder states. Never show fake sample data. Show an honest empty state instead.
| Layer | How | What it covers |
|---|---|---|
| Types | npm run typecheck, npm run check:functions |
Strict TypeScript for the app, the Edge Functions and their tests |
| Lint | npm run lint |
ESLint (React hooks, refresh, TypeScript rules) |
| App logic | npm run test:app |
tests/*.test.ts: scenes engine, Quick Create and its sync with the builder, recipient playthroughs of every template category, AI editing and the generator, writing help, dashboard, About content |
| Edge Functions | npm run test:functions |
supabase/tests/*.test.ts: AI adapters, schemas, service fallback and limits, handlers, admin authorisation, admin console validation, payments and webhook signatures (no network) |
| Database | supabase/verification/*.sql in the SQL Editor |
RLS, grants, triggers and RPCs per migration; each script rolls back and prints PASS: ... |
| Manual | Verifying | Sign-up, onboarding, sign-out and redirects in the browser |
Before shipping a change, run:
npm run lint
npm run test:app
npm run test:functions
npm run build
If anything under supabase/functions/ changed, also run npm run bundle:functions and redeploy the affected functions.
024_<name>.sql). In it:
owner_id uuid not null default auth.uid() references auth.users on delete cascade, plus created_at and updated_at.alter table … enable row level security; and write select / insert / update / delete policies using (select auth.uid()) = owner_id.public.set_updated_at() trigger.owner_id and any foreign keys.002_storage_avatars.sql).npm run db:types. Never hand-write row types in feature code; derive them with Tables<'people'>.src/features/<feature>/ containing:
api/: plain async functions that call supabase and throw on errorhooks/: TanStack Query hooks with a <feature>Keys query-key factorycomponents/: feature-specific UI built from components/uipages/: route components (default export, lazy-loaded)app/paths.ts, register the lazy page in app/router.tsx, and add it to app/navigation.ts if it needs a nav entry.supabase/functions/<name> (Edge Functions). Never put it in the browser.Built: accounts and onboarding, people, occasions, Memory Vault and memory stories, the experience data layer and builder, interactive blocks and scenes, 31 templates, Quick Create (with guest mode), recipient links, scheduling and Surprise Drop, friend contributions, group links, Group Secret, AI writing, editing and generation, the admin console, Razorpay payments, the About page, and light and dark mode.
Planned (not implemented):
editor collaborators, real-time presence, and inviting collaborators by email.reminder_days_before, and email/SMS delivery of scheduled experiences and contribution invitations.