diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..33062e2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,204 @@ +# Contributing to Trousseau + +Thank you for thinking about it. Trousseau is used by real couples planning +real weddings, so a fix here can save someone a bad evening with a +spreadsheet. Bug reports, fixes, documentation and whole new tools are all +welcome. + +**The one rule that matters most:** never paste a real guest list, real names, +emails or dietary requirements into an issue, a pull request, a test fixture +or a screenshot. Use the guided tour's example wedding, or invent people. + +--- + +## Ways to help + +| You have… | Do this | +| --- | --- | +| Ten minutes | Try the [hosted app](https://trousseau-suite.vercel.app), and open an issue for anything confusing. Confusion is a bug. | +| An hour | Pick an issue labelled `good first issue`, or fix a doc that was wrong for you. | +| A weekend | Take a tool proposal from [ROADMAP.md](ROADMAP.md). | +| A job nothing does | Build a tool. Read [docs/BUILDING-A-TOOL.md](docs/BUILDING-A-TOOL.md) first. | + +--- + +## Reporting a bug + +Open an issue with: + +1. **What you did.** The steps, starting from which page. +2. **What happened.** Include the exact error text if there was one. +3. **What you expected.** +4. **Where.** The hosted site or your own copy, and your browser. +5. **A screenshot**, with names blurred, if it is visual. + +"Not sure how to reproduce it; it happened while I was moving tables" is still +a useful report. Leave the rest blank if you don't know it. + +**Security problems** (one account reading another's wedding, a guest link +revealing more than one seat, anything touching row-level security): do not +open a public issue. Use GitHub's **Report a vulnerability** button on the +Security tab, so it can be fixed before it is public. + +## Suggesting a feature + +Open an issue that starts from the job, not the solution: "I needed to know +who was bringing the cake stand" beats "add a cake stand field". Then: + +- Check [ROADMAP.md](ROADMAP.md). It may already be planned, or explicitly + ruled out with a reason. +- Check `docs/superpowers/specs/`. Design decisions are written down there + rather than living in anyone's head. +- If it could be a whole tool, [docs/BUILDING-A-TOOL.md](docs/BUILDING-A-TOOL.md) + has five questions to answer first. Most ideas turn out to be a feature of + an existing tool, and that is a good outcome. + +Some things are ruled out on purpose and will be closed with a pointer to the +reason. These include RSVP collection (Joy and similar do it well, and +Trousseau imports the result), a paid tier of any kind, and an admin panel +that can browse weddings. + +--- + +## Local setup + +### Prerequisites + +- **Node.js 20 or newer.** CI runs Node 22, so that is the safest choice. +- **npm 10 or newer.** +- Nothing else for local development: no database, no account, no Docker. + +### Install and run + +```sh +git clone https://github.com/JFrusher/Trousseau.git +cd Trousseau + +npm ci # installs the root package and the suite workspace +npm run build # builds the contract package into dist/ +npm run dev -w suite # http://localhost:3000 +``` + +**Do not skip `npm run build`.** The suite depends on the contract package via +`"@jfrusher/trousseau": "file:.."`, which resolves to the root `dist/`. A +fresh clone has no `dist/`, and the install succeeds anyway. The failure +turns up later as: + +``` +Module not found: Can't resolve '@jfrusher/trousseau' +``` + +If you see that, run `npm run build` at the root and try again. + +To work on accounts, sync or guest links you need a Supabase project. See +[docs/SELF-HOSTING.md](docs/SELF-HOSTING.md). For sync work alone, +`SYNC_IN_MEMORY=1` in `suite/.env.local` runs the sync endpoints against an +in-process map (development only). + +### Where things live + +```text +src/ the data contract: zod schemas and the file format (MIT) +suite/ the Next.js application (AGPL-3.0-or-later) + apps/ Seating (tableaux), Place cards (plaque), + Timeline (cadence), Delegation (brigade) + lib/ the shared document, sync, accounts, and newer tools + components/ the shell, and the newer tools' panels + app/ routes and API + e2e/ Playwright specs, run against a production build +supabase/migrations/ database migrations, applied in filename order +docs/ self-hosting, building a tool, specs and plans +scripts/ bundle, sync and cross-slice validation utilities +``` + +The first four tools keep their original code names (Tableaux, Plaque, +Cadence, Brigade) as folder names. The product calls them Seating, Place +cards, Timeline and Delegation. + +--- + +## The rules every change keeps + +These are why the tools never disagree. A PR that breaks one will be asked to +change, however good the rest is. + +1. **A tool writes only its own slice** of the wedding document, and copies + every other key untouched, including keys it does not recognise. +2. **One editor per fact.** A guest's name is corrected in one place, and + everything else reads it. +3. **Fail loudly.** A missing configuration refuses to start, and a save that + failed says so. Nothing silently falls back. +4. **No guest data to advertising, analytics or error-reporting services.** Account sync may send it only to the configured storage backend described in the Privacy Policy. +5. **Privacy text matches the code.** If you change what the app collects or + sends, change the Privacy Policy in the same PR. + +--- + +## Checks to run before a pull request + +These checks cover the validations CI runs (`.github/workflows/ci.yml`). Build the contract +package first; everything else needs it. + +| Check | Command | +| --- | --- | +| Build the contract package | `npm run build` | +| Typecheck the contract | `npm run typecheck` | +| Typecheck the suite | `npm run typecheck -w suite` | +| Test the contract | `npm test` | +| Test the suite | `npm run test -w suite` | +| Build the suite | `npm run build -w suite` | +| End-to-end, with axe | `npm run e2e -w suite` (after the suite build; needs Playwright's Chromium) | +| Dependency audit | `npm audit --audit-level=high` | + +If `tsc` in the suite reports `Cannot find name 'LayoutProps'`, it is not a +real error. Next generates that type into `.next/types` during a build. Run +`npm run build -w suite` once and check again. + +--- + +## Pull requests + +1. **One change per PR.** A bug fix, or a feature, not both. +2. **Branch names** say what kind of work it is: `fix/…`, `feat/…`, `docs/…`, + `refactor/…`. +3. **Commit messages** say what changed for the person using it, in plain + English: "Clocks nobody set stay unset, everywhere", not "fix: null check". +4. **Test the behaviour, not the implementation.** A bug fix comes with a test + that failed before the fix. New UI gets a Playwright spec if it can be + reached from the keyboard. +5. **Accessibility is checked.** The e2e run includes axe on every page, so a + new page or dialog must pass it. +6. **Open against `main`** and describe what a user would notice. Screenshots + help, with made-up names. +7. **CI must be green.** If a failure looks unrelated, say so in the PR rather + than re-running until it passes. + +### Changing the database + +Add a new, timestamped file to `supabase/migrations/`. Never edit one that +has already been applied. Every table gets row-level security, and a change to +a `security definer` function needs a test. Read the +[database review](docs/superpowers/specs/2026-09-29-database-review.md) first. +Supabase's Security Advisor suggests "fixes" that would lock every couple out +of their own wedding. + +### Changing the contract package + +`src/` is published to npm as `@jfrusher/trousseau` and other tools may +depend on it. Changes must be additive: a new optional field, never a renamed +or removed one. `npm run verify` checks that the published build still +imports cleanly. + +--- + +## Code of conduct + +Be kind, be patient, and assume good faith. Many people here are planning +their own wedding and are already stressed. Harassment or personal attacks +of any kind will get you removed from the project. + +## Licence + +By contributing, you agree that your contribution is licensed under the +licence of the part you changed: **AGPL-3.0-or-later** for `suite/`, and +**MIT** for the contract package at the root. See [LICENSE](LICENSE). diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md deleted file mode 100644 index 4e9258f..0000000 --- a/CONTRIBUTION.md +++ /dev/null @@ -1,111 +0,0 @@ -# Contributing to Trousseau - -Thank you for taking the time to contribute! We welcome bug fixes, documentation improvements, feature additions, and performance enhancements. - -## Code of Conduct - -Please treat everyone with respect, patience, and kindness. We want this project to be a welcoming environment for developers of all backgrounds. - ---- - -## Development Setup - -### Prerequisites - -* **Node.js**: v22 or higher -* **npm**: v10 or higher - -### Getting Started - -1. **Fork and clone the repository:** - - ```bash - git clone [https://github.com/JFrusher/Trousseau.git](https://github.com/JFrusher/Trousseau.git) - cd Trousseau - ``` - -2. **Install dependencies:** -```bash -npm ci - -``` - - -3. **Build core packages:** -Before running tests or launching workspace apps, compile the shared core packages: -```bash -npm run build - -``` - - -4. **Start the local development server:** -```bash -npm run dev -w suite - -``` - - - ---- - -## Development Workflow - -### Project Structure - -* `/src` — Core domain logic and shared library modules (`@jfrusher/trousseau`). -* `/suite` — Next.js App Router application workspace. -* `/scripts` — Build and validation utilities. - -### Branch Naming Conventions - -Use descriptive branch names prefixed with the category of work: - -* `feat/your-feature-name` -* `fix/bug-description` -* `docs/update-readme` -* `refactor/component-cleanup` - ---- - -## Testing & Quality Checks - -Our CI pipeline enforces type safety, unit testing, and linting. Make sure these pass locally before submitting a Pull Request. - -### Commands - -| Action | Command | -| --- | --- | -| **Typecheck Root** | `npm run typecheck` | -| **Typecheck Suite** | `npm run typecheck -w suite` | -| **Run Core Tests** | `npm run test` | -| **Run Suite Tests** | `npm run test -w suite` | -| **Build Core** | `npm run build` | -| **Build Suite** | `npm run build -w suite` | - -> **Note:** Running tests or typechecks on workspace applications requires the core package (`npm run build`) to be compiled first. - ---- - -## Building a New Tool - -Most new tools start as a job someone needed doing for their own wedding. Before writing code, read **[docs/BUILDING-A-TOOL.md](docs/BUILDING-A-TOOL.md)**. It covers: - -* deciding whether an idea is a tool, a feature of an existing tool, or already built; -* proposing it with a spec and a plan; -* the rules every tool keeps; -* connecting it to the other tools; -* every file a new tool touches, and how to test it. - ---- - -## Submitting a Pull Request - -1. **Keep changes focused:** A PR should address a single bug fix or feature enhancement. -2. **Add tests:** Ensure new features or bug fixes include corresponding unit or integration tests in Vitest. -3. **Pass local checks:** Run typechecks and tests locally across all workspaces. -4. **Push and open PR:** Push your branch to GitHub and open a Pull Request against the `main` branch. -5. **CI Status:** Ensure all GitHub Actions status checks pass. - -Thank you for contributing! - diff --git a/README.md b/README.md index 7da8ebd..9b034ae 100644 --- a/README.md +++ b/README.md @@ -1,214 +1,229 @@ -# Trousseau +
-**Plan a whole wedding in one place, without five tools disagreeing about it.** +# 💍 Trousseau + +### Plan a whole wedding in one place, without five tools disagreeing about it. + +**Free, open source and private. No paid tier, no ads, no upsell, no sign-up to start.** + +[![Licence: AGPL-3.0 app, MIT contract](https://img.shields.io/badge/licence-AGPL--3.0%20app%20%C2%B7%20MIT%20contract-5b4bd5)](LICENSE) +[![GitHub stars](https://img.shields.io/github/stars/JFrusher/Trousseau?style=flat&logo=github&label=stars)](https://github.com/JFrusher/Trousseau/stargazers) +[![CI](https://github.com/JFrusher/Trousseau/actions/workflows/ci.yml/badge.svg)](https://github.com/JFrusher/Trousseau/actions/workflows/ci.yml) +[![Self-hostable](https://img.shields.io/badge/self--hostable-Next.js%20%2B%20optional%20Supabase-2f855a)](docs/SELF-HOSTING.md) +[![No account needed](https://img.shields.io/badge/account-not%20needed-2f855a)](https://trousseau-suite.vercel.app) +[![PRs welcome](https://img.shields.io/badge/PRs-welcome-e05d44)](CONTRIBUTING.md) [**Open Trousseau →**](https://trousseau-suite.vercel.app)  ·  -[Run your own copy](docs/SELF-HOSTING.md)  ·  -[How it works](#how-it-works) +[Run your own copy](#-run-your-own-copy)  ·  +[How it works](#-how-it-works)  ·  +[Roadmap](ROADMAP.md)  ·  +[Contribute](CONTRIBUTING.md) -Free, open source, and free forever. No paid tier, no upsell, no trial. +![Trousseau: the whole wedding in one place, with the front page showing where things stand](marketing/assets/images/hero-overview.png) -![The Trousseau front page — one wedding, five tools, and what is left to do](docs/images/home.png) +
--- -## What it is +## 💡 Why we built this -Five planning tools that share one document, so a change in any of them shows -up correctly in the others. +Wedding software is rarely free. The planning apps are paid for some other +way: vendor marketplaces, registry commissions, adverts, and upsells to the +printed stationery you were about to buy. Your guest list is the asset. It +holds names, emails, family relationships, and dietary needs that are +sometimes medical. -Seat someone in the room and the place cards already know their table. Move the -ceremony by ten minutes and every job hanging off it moves with it. Nothing is -re-typed, and nothing quietly disagrees. +The tools also don't talk to each other. The seating chart, the place cards +and the run sheet end up as three copies of one guest list, drifting apart. +When this project began, two apps disagreed about **what day the wedding +was**. -| Tool | What it does | -| --- | --- | -| 🪑 **Seating** | Draw the room to scale, then put people in it | -| 💌 **Place cards** | Print-ready cards and table signs, with the table numbers already filled in | -| 🕒 **Timeline** | The running order — what happens when, and what collides | -| 📋 **Delegation** | The jobs, and the people doing them | -| 📷 **Group shots** | The family photo list, built from who's related to whom | +Trousseau is the opposite of that: -> [!NOTE] -> You do not need an account. Open the app and start — everything is saved in -> your browser. An account only adds syncing between devices and sharing with -> your partner. +- **Free forever.** Not a trial, not freemium. There is no paid version to be + upsold to, and the AGPL stops anyone building a closed, paid fork of the + hosted service. +- **Private by default.** Open it and plan. With no account, your wedding + stays in your browser and nothing leaves the device. +- **One wedding, many tools.** Every tool reads and writes the same document, + so a change in one shows up correctly in all the others. --- -## Getting started +## ✨ What it does -### The quickest possible start +Seat your guests, and one click puts every table number on the place cards. +Move the ceremony by ten minutes and every job hanging off it moves too. +Nothing is retyped. -1. Open **[trousseau-suite.vercel.app](https://trousseau-suite.vercel.app)**. -2. Press **Data**, and put in your names, your venue and the date. -3. Import your guest list as a CSV — exports from Joy, Zola, The Knot or your - own spreadsheet all work, and the column mapper will ask about anything it - cannot guess. -4. Open **Seating** and drag a few tables onto the canvas. +### The tools -That is enough to be useful. Everything else builds on it. +| | Tool | What it does | +| --- | --- | --- | +| 👥 | **Guests** | The one guest list everything builds on. Import a CSV from Joy, Zola, The Knot or your own spreadsheet. The column mapper guesses what it can and asks about the rest. | +| 🪑 | **Seating** | Draw the room to scale in real units, then put people in it. Keep-together and keep-apart rules, and a live dietary breakdown. | +| 💌 | **Place cards** | Print-ready cards and table signs, with table numbers filled in from the room. | +| 🕒 | **Timeline** | The running order of the day. It shows what collides, what runs past curfew, and what can't be reached in time. | +| 📋 | **Delegation** | The jobs, and who is doing them, hung off each part of the day. | +| 📷 | **Group shots** | The family photo list, built from who is related to whom. | -### Planning together +The six above are there from the start. Add these from the **toolbox** when +your wedding needs them: -Weddings have two people in them, so an account has room for two. +| | Tool | What it does | +| --- | --- | --- | +| 💍 | **Ceremony** | Who walks down the aisle, in what order, and to what. The order of service, music and readings, with cues on the Timeline. | +| 📦 | **Boxes** | What is packed in which box, and where each box has to be, by when. | +| 🍷 | **Bar** | How much drink to buy, in bottles and cases, and roughly what it costs. | +| 💷 | **Money** | What each supplier costs, what is paid, and what falls due, against your budget. | +| ✅ | **Checklist** | What to have done before the day, each item with a date. | +| 📱 | **Binder** | The day on your phone: what's on now and next, who to ring, and where a guest sits. Works without signal. | -1. Sign in with your email. There is no password — you get a link, you click - it, you are in. -2. From **Your account**, invite your partner by email. -3. You are both now editing the same wedding, from your own devices. +Removing a tool only hides it. What you made in it stays, and comes back when +you add the tool again. -If you both change the same thing at once, Trousseau says so and asks which -version to keep. It never silently picks one. +### Around the tools ---- +- 🖨️ **One PDF pack.** The floor plan, run sheet, job list and group shot list, + printed from the wedding as it stands. +- 🔗 **Guest and supplier links.** A guest sees their own seat and nothing + else. A supplier sees their part of the day and can confirm it. +- 🤝 **Plan together.** Two partners and a planner, with sign-in by magic link + (no passwords). Real-time sync, version history with restore, and conflicts + are shown, never silently resolved. +- 🗂️ **Planner mode.** Many weddings per account, and a library of reusable + processionals, box sets and bar settings. +- 🧭 **A guided tour** with a complete example wedding, and a ⌘/Ctrl-K command + palette. -## A short guide +![Every tool, one wedding: the front page, the guest list and every desktop tool](marketing/assets/images/tools-grid.png) -### 1. Start with the room +**Seat a guest, and her place card has her table.** Drag Zainab onto Table 13, +press *Use the room* in Place cards, and her card reads "Table 13". -Open **Seating**. Drag table shapes from the toolbar onto the canvas, then drag -guests from the left-hand list onto seats. +![Seating a guest, then opening her place card with the table filled in](marketing/assets/motion/seat-to-card.gif) -The room is drawn to scale in real units, so a table that does not fit on the -canvas is a table that will not fit on the day. +**Move the ceremony, and the day follows.** Pinned at 13:30, moved to 14:00: +drinks, photos and dinner all move with it. Later still, and it tells you what +no longer fits. -The panel on the right keeps a running count of who is seated, and breaks the -guest list down by dietary requirement as you go. +![Moving the ceremony in Timeline: every block after it moves, then a collision is flagged](marketing/assets/motion/ceremony-moves.gif) -![Building the room in Seating — the guest list, the floor plan, and the dietary breakdown](docs/images/seating.png) +**The Binder, on the day.** What is on now, who to ring, where a guest sits, +and the shot list to tick off, on a phone, with or without signal. -### 2. Plan the day +

The Binder on a phone: now, the running order, who to ring, find a guest, the shot list

-Open **Timeline**. Add blocks in lanes — the main day, suppliers, transport, -whatever your day actually needs. Give a block a duration, then either pin it -to a time or let it follow whatever comes before it. +More screenshots, framed images and clips for sharing are in +[`marketing/assets/`](marketing/assets/). -That distinction is the useful part. Pin the ceremony, let everything after it -follow, and moving the ceremony moves the rest of the day with it. +--- -Anything that collides, or runs past your curfew, is flagged while you work. +## 🚀 Get started in two minutes -The Location field offers the names of spaces you drew in the room, so -"Orangery" on the run sheet is the same Orangery on the floor plan. It still -takes free text — a church nobody is going to draw a floor plan of is a real -place. +1. Open **[trousseau-suite.vercel.app](https://trousseau-suite.vercel.app)**. + There is no sign-up. +2. Press **Data**, and put in your names, your venue and the date. +3. Import your guest list as a CSV. +4. Open **Seating** and drag a few tables onto the canvas. -![The running order in Timeline — lanes, gaps, and what collides](docs/images/timeline.png) +That is enough to be useful. Everything else builds on it. If you would rather +look around first, the guided tour opens an example wedding with 100 guests +and a full day. -### 3. Hand out the jobs +> [!NOTE] +> An account only adds syncing between devices and planning with your partner +> or planner. Sign in with your email and you get a link: no password. -Open **Delegation**. Every block of the day is a row you can hang jobs off. Add -teams and people, then click a job and click who is doing it. +--- -Someone already on your guest list is added by picking them, not by typing -their name again — so their name is only ever corrected in one place. +## 🏠 Run your own copy -### 4. Print the cards +Self-hosting is a supported path, not a theoretical one. The hosted instance +is the easy option; your own copy gives you your own domain and, with sync, +your own database. Deployments hosted off Vercel have no page analytics; +error reporting only runs if you set a Sentry DSN. -Open **Place cards**. Press **Use the room** and the guest list arrives with -table numbers already attached. Design the card by binding `{{First Name}}`, -`{{Table}}` and the rest onto your artwork. +### Local only: no backend, no account -```text -┌─────────────────────────────┐ -│ │ -│ Charis Smith │ 85 × 55mm, 9 per A4 sheet -│ │ -│ Table 1 │ -│ │ -└─────────────────────────────┘ +```sh +git clone https://github.com/JFrusher/Trousseau.git +cd Trousseau +npm ci # installs the contract package and the suite together +npm run build # builds the shared contract package; do not skip this +npm run dev -w suite ``` -> [!TIP] -> Print two test cards on plain paper and hold them against your real card -> stock before committing. The export refuses to print a card with a missing -> font or a hole where a monogram should be — cheaper than finding out after -> the good stock has gone through. +Open . Every tool works, and the wedding lives in your +browser's IndexedDB. -### 5. Take the pack +### With accounts and sync -The front page has one button that produces the floor plan, the run sheet, the -job list and the group shot list as a single PDF, printed from the wedding as -it currently stands. +Accounts, syncing and guest links need a [Supabase](https://supabase.com) +project. Its free tier is enough for a wedding. In outline: -Place cards are deliberately not in it. They go on card stock, and an A4 binder -and a tray of card are two different trips to the printer. +1. `cp suite/.env.example suite/.env.local` and fill in the four Supabase + variables. +2. Apply every file in `supabase/migrations/` in filename order. The + row-level security policies are what keep one couple's wedding from + another's. +3. `npm run build -w suite && npm start -w suite`, or deploy anywhere that + runs Next.js. -### 6. Send guests their table +**On Vercel:** import this repository as a new project, set **Root +Directory** to `suite`, and add the same environment variables. That is how +the hosted instance runs. -A share link shows one guest their own seat and nothing else. +**[docs/SELF-HOSTING.md](docs/SELF-HOSTING.md)** covers every environment +variable, the migration order, the two mistakes that catch everyone out, and +how to check your instance actually works rather than merely starting. -The decryption key travels in the URL fragment, which browsers never send to a -server. The link works without anyone — including whoever is hosting the -app — holding a readable copy of your guest list. +> [!NOTE] +> There is no Docker image. That is deliberate: the setup is one Node app and +> an optional Supabase project, and a container would be a second thing to +> maintain rather than a simplification. If you need one, open a discussion +> and say what it would make easier. --- -## Your data - -**Everything works with no account.** Open the app and it saves to your -browser. Nothing of your wedding leaves the device. The hosted site counts -visits to its pages, with no cookie and nothing from the wedding in it; the -[Privacy Policy](https://trousseau-suite.vercel.app/privacy) says exactly what. - -**With an account**, your wedding syncs so you and your partner can both work -on it. It is stored encrypted at rest, and database-level rules mean no other -account can read it — not even by accident. - -**You can always take it out.** From **Your account**, *Download my wedding* -gives you the whole thing as one `.trousseau.json` file. That is the same -format the app itself uses, so it opens straight back into Trousseau — hosted -here, or on a copy you run yourself. - -**Deleting your account deletes your data.** If your partner is still on the -wedding, the wedding stays with them; if you were the last one, it goes. +## 🔒 Your data + +- **No account, no upload.** The app saves to your browser, and nothing from + your wedding leaves the device. The hosted site counts visits to its pages, + with no cookie and nothing from the wedding in it. Every address is cut to + its route first. The [Privacy Policy](https://trousseau-suite.vercel.app/privacy) + says exactly what is counted. +- **With an account**, your wedding syncs between you, your partner and your + planner. It is stored encrypted at rest, and database-level rules mean no + other account can read it. +- **Guest links** show a guest their own seat and nothing else. The server + stores the page encrypted and serves only ciphertext. The key travels after + the `#` in the link, which browsers never send to a server. Members of the + wedding hold the key so they can republish as seats change. +- **You can always take it out.** *Download my wedding* gives you the whole + thing as one `.trousseau.json` file. That is the same format the app uses, + so it opens straight back into Trousseau, hosted or on your own copy. +- **Deleting your account deletes your data.** If your partner is still on the + wedding, it stays with them. If you were the last one, it goes. > [!IMPORTANT] > There is no admin panel and no support login, so nobody browses weddings. -> The database is encrypted at rest, not end to end: whoever runs an +> The database is encrypted at rest, **not end to end**: whoever runs an > instance administers its database and could read what is in it, and the > Privacy Policy says so plainly. That is why support is "send us a -> screenshot" rather than "let me look at your account", and why the app -> works in full without an account at all. +> screenshot" rather than "let me look at your account", why the app works +> in full without an account, and why you can run your own. --- -## Run it yourself - -The hosted instance is the easy path, but it is not the only one. Trousseau is -AGPL software and self-hosting is genuinely supported, not theoretically -possible. - -```sh -git clone https://github.com/JFrusher/Trousseau.git -cd Trousseau - -npm install -npm run build # builds the shared contract package — do not skip - -cd suite -npm install -npm run dev -``` - -That gives you the whole suite locally, with no backend and no account. - -Adding accounts and sync means a Supabase project and its migrations. -**[docs/SELF-HOSTING.md](docs/SELF-HOSTING.md)** covers all of it — every -environment variable, the migration order, and a section on how to check your -instance actually works rather than merely starting. - ---- - -## How it works +## 🧠 How it works ### One document, one owner per slice The rule everything rests on: > A tool rewrites **only its own slice**, and copies every other key -> byte-for-byte — including keys belonging to tools that do not exist yet. +> byte-for-byte, including keys belonging to tools that do not exist yet. ```mermaid flowchart LR @@ -234,22 +249,32 @@ flowchart LR ``` Solid lines are what a tool writes. Dotted lines are what it reads from the -others — and those are the whole point. +others, and those are the whole point. + +`timeline` holds the source of the day: which block is pinned to a time, and +which follows after a gap. `day` holds the clock times those work out to. +Delegation reads the second and never runs a scheduler of its own. That is +why moving the ceremony by ten minutes moves every job hanging off it, +without anything else recalculating. + +The merge is enforced in one place, and it works on *raw stored data* rather +than a parsed document. A bug in a schema should at worst refuse a read, never +destroy a write. Unknown keys survive at every level, which is how a new tool +can be added without releasing a new version of the others. -`timeline` holds the source of the day: which block is anchored, which follows -after a gap. `day` holds what those work out to as actual clock times. -Delegation reads the second and never runs a scheduler of its own, which is why -a ceremony moving by ten minutes moves every job hanging off it without -anything recalculating. +### The day is resolved, not typed -The merge is enforced in one place, and deliberately operates on *raw stored -data* rather than a parsed document: a bug in a schema should at worst refuse a -read, never destroy a write. Unknown keys survive at every level, which is how -a sixth tool could be added without releasing a new version of the other five. +Timeline's resolver is one pure function that the screen and every PDF read. +In each lane, a pinned block starts at its time and a following block starts +where its predecessor ends, plus its gap. When a chain of following blocks +overruns the next pinned time, the overrun is taken out of blocks you marked +as squeezable. Whatever cannot be absorbed is reported as the collision it +is. Travel time between places is checked, and sunset and golden hour are +computed offline, with no network and no timezone database. ### Checks no single tool can run -Each tool only sees its own slice, so the interesting problems live between +Each tool sees only its own slice, so the interesting problems live between them. | | | @@ -262,90 +287,85 @@ them. | 🔴 error | a day block in a lane that does not exist | | 🟡 warning | confirmed guests with no table, or no dietary answer | -The front page runs its own version of this and shows what is left: cards -printed from a stale file rather than the live room, a dietary requirement -recorded for someone whose card has nowhere to show it, a block happening -somewhere that is not on the floor plan. +The front page runs its own version and shows what is left to do. -Only the gaps *between* tools. Anything one tool can see for itself, it reports -itself. +### The stack -### What is in here - -The first four tools were standalone applications before this and keep their -own stores and stylesheets; only the file deciding where their work is saved -was redirected into the shared document. Group shots was the first built here -rather than adopted, so it has none of that and reads the shared document -directly. +**Next.js 16** (App Router) · **React 19** · **TypeScript** · **Zustand** · +**zod 4** · **Tailwind CSS 4** · **Supabase** (Postgres with row-level +security, magic-link auth) · **pdf-lib / jsPDF** for print · **Vitest**, +**Playwright** and **axe** for tests. ```text -suite/ the web application +suite/ the web application (AGPL-3.0-or-later) apps/ Seating, Place cards, Timeline, Delegation - lib/ the shared document, sync, accounts, Group shots, design tokens - components/ the shell around the tools, and Group shots' panels - app/ routes, API, account and guest-link pages -src/ the data contract, published as @jfrusher/trousseau + lib/ the shared document, sync, accounts, and the newer tools + components/ the shell around the tools, and the newer tools' panels + app/ routes, API, account, guest and supplier pages +src/ the data contract, published as @jfrusher/trousseau (MIT) supabase/ database migrations -docs/ self-hosting, data notes, building a tool, specs and plans +docs/ self-hosting, building a tool, and dated specs and plans ``` --- -## Where this came from +## 🤝 Contributing -Trousseau was built for one specific wedding — which is the only reason its -constraints were ever honest. Real guest names and dietary requirements, tools -that genuinely must not overwrite each other, and a date that does not move. +Issues and pull requests are welcome. **[CONTRIBUTING.md](CONTRIBUTING.md)** +has the setup, the checks CI runs, and how to report a bug without sharing +anyone's personal details. -Two apps once disagreed about what day the wedding was. The cross-slice checks -above exist because of that, not because they seemed like a good idea. +- **Found a bug?** Open an issue with what you did and what happened. + **Never paste your guest list.** A screenshot with names blurred is plenty. +- **Want to change something?** Design decisions are written down in + `docs/superpowers/specs/`, so you can tell whether an idea fits before + writing code. +- **Want to build a tool?** A job nothing in Trousseau does for you yet is the + best reason to. **[docs/BUILDING-A-TOOL.md](docs/BUILDING-A-TOOL.md)** walks + through the whole journey. -That wedding has happened. Trousseau is now being built as something other -couples can use, which is why it grew accounts, real cloud storage and a -self-hosting story. The design did not change, because the design was the part -that was working. +## 🗺️ Roadmap -Where it is going next is in -**[docs/PRODUCT-ROADMAP.md](docs/PRODUCT-ROADMAP.md)** — a living document -covering what is built, what is decided, and what is still open. +Everything in the plan up to now is built: eleven tools, accounts, real-time +sync, planner mode and the Binder. Next come tools feeding each other. Boxes +will appear on job sheets, Bar spend will count against the budget, and +Ceremony will show on the Binder. **[ROADMAP.md](ROADMAP.md)** has the +milestones and the issues to pick up. --- -## Contributing +## 🌱 Where this came from -Issues and pull requests are welcome. +Trousseau was built for one specific wedding. That is the only reason its +constraints were ever honest: real guest names and dietary requirements, +tools that genuinely must not overwrite each other, and a date that does not +move. -- **Found a bug?** Open an issue describing what you did and what happened. - **Never paste your guest list** — a screenshot with names blurred, or a - description, is plenty. -- **Want to change something?** The roadmap explains what is planned and why. - Design decisions are written down in `docs/superpowers/specs/` rather than - living in anyone's head, so it should be possible to tell whether an idea - fits before writing any code. -- **Want to build a tool?** A job nothing in Trousseau does for you yet is the - best reason to. **[docs/BUILDING-A-TOOL.md](docs/BUILDING-A-TOOL.md)** walks - through the whole process: whether the idea is a tool, proposing it, - connecting it to the other tools, and every file it touches. -- **Running the tests:** `npx vitest run` from `suite/` covers all five tools - and the shell; `npm test` at the root covers the contract package. +That wedding has happened. Trousseau is now being built for other couples, +which is why it grew accounts, real cloud storage and a self-hosting story. +The design did not change, because the design was the part that was working. --- -## Licence +## 📜 Licence Two licences, because this repository holds two different things. -The **application** — everything in `suite/` — is -**[AGPL-3.0-or-later](LICENSE-AGPL)**. Trousseau is free and always will be, -and the AGPL is what keeps it that way: run it, change it, host it for friends. -Host a modified version for other people and they are entitled to your source -too. - -The **contract package**, `@jfrusher/trousseau`, is **[MIT](LICENSE-MIT)**. It -is the schemas and the file format, kept permissive on purpose so that a tool -nobody has written yet can depend on it. +- The **application**, everything in `suite/`, is + **[AGPL-3.0-or-later](LICENSE-AGPL)**. Run it, change it, host it for + friends. If you host a modified version for other people, they are entitled + to your source too. +- The **contract package**, `@jfrusher/trousseau`, is **[MIT](LICENSE-MIT)**. + It holds the schemas and the file format, kept permissive on purpose so that + a tool nobody has written yet can depend on it. Fonts are under the SIL Open Font Licence; see the `OFL-*.txt` files beside them. There is no paid tier and there never will be. That is the reason this exists. + +
+ +**If Trousseau saves you an evening with a spreadsheet, a ⭐ helps other couples find it.** + +
diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..7d32109 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,120 @@ +# Roadmap + +Where Trousseau is, and where it could go next. This page is the short +version for contributors. The full record of every decision, and why it was +made, is in **[docs/PRODUCT-ROADMAP.md](docs/PRODUCT-ROADMAP.md)** and the +dated specs in `docs/superpowers/specs/`. + +**How to read this:** ✅ is built and in the app. 🔜 is proposed, and a good +thing to pick up, but each proposal needs the maintainer's yes before code is +merged. Comment on the matching issue first. 💭 is further out and needs a +design pass before anyone builds it. + +--- + +## ✅ V1: one wedding, every tool agreeing (shipped) + +The core promise: a set of planning tools that share one document, so nothing +is retyped and nothing disagrees. + +- ✅ **The shared document.** One owner per slice, unknown keys preserved, and + a published MIT data contract (`@jfrusher/trousseau`). +- ✅ **Guests.** One guest list, one importer (Joy, Zola, The Knot, any CSV), + with a preview before anything is written. +- ✅ **Seating.** A room drawn to scale, groups, families, keep-together and + keep-apart rules, and a dietary breakdown. Now fully TypeScript. +- ✅ **Place cards.** Cards and table signs bound to the seating plan, with + print checks. +- ✅ **Timeline.** Pinned and following blocks, squeezable blocks, collisions, + curfew, travel between places, sunset and golden hour, and calendar files. +- ✅ **Delegation** and **Group shots.** +- ✅ **Checks between tools**, and the front page's "what is left". +- ✅ **The PDF pack.** +- ✅ **Works with no account.** Local-first, stored in IndexedDB. +- ✅ **Self-hosting runbook**, tested on a fresh clone. + +## ✅ V1.1: planning together, and the toolbox (shipped) + +- ✅ **Accounts.** Magic-link sign-in, two partners per wedding, and signing in + never silently replaces a wedding. +- ✅ **One live document**, with real-time sync, presence, one undo history, + and version history with restore. +- ✅ **Planner mode.** A planner role, many weddings per account, and a + library of reusable processionals, box sets and bar settings. +- ✅ **The toolbox.** Add or remove tools per wedding without losing work. +- ✅ **Ceremony, Boxes, Bar, Money, Checklist** and the **Binder**, which + works offline on a phone. +- ✅ **Guest seat links** and **supplier links** with confirmation. +- ✅ **Guided tour** with an example wedding, and the ⌘/Ctrl-K palette. +- ✅ **Download my wedding** as a single `.trousseau.json` file, and account + deletion that really deletes. +- ✅ **Retention.** An account wedding nobody writes to for 24 months is + deleted by a daily sweep, as the Privacy Policy states. + +--- + +## 🔜 V1.5: tools feeding each other + +Every tool already reads the shared document. These proposals connect the +newer tools to each other. Each one keeps the core rule: a tool writes only +its own slice and reads the others'. They are well scoped and the best place +for a first substantial contribution. + +| From → To | What it does | +| --- | --- | +| **Boxes → Delegation** | Whoever is taking a box sees "Box 3 to the house by 09:00" on their job sheet. This is derived from the box, never stored as a job, so it follows the block if the day moves. | +| **Boxes → Binder** | Find a box or an item on the day: "where are the rings?" | +| **Ceremony → Timeline** | The processional's cues shown inside the ceremony block, read-only. | +| **Ceremony → Binder** | The order of walking, on a phone, on the day. | +| **Bar → Timeline** | Reception, meal and evening hours read from the blocks the couple picks, rather than typed twice. | +| **Bar → Money** | The estimated drinks spend shown against the budget, as planned rather than paid. | +| **Bar → Checklist** | "Buy the drinks" and "Collect the ice", dated back from the day. | +| **Bar → Boxes** | Crates as boxes, attached to the bar's block. | +| **Timeline → Supplier links** | Each supplier's calendar file on their own call sheet. | + +Source: [toolbox and new tools design](docs/superpowers/specs/2026-09-29-toolbox-and-new-tools-design.md), +"Tools feeding each other". + +--- + +## 💭 V2: beyond the couple and the desk + +Explicitly deferred so far. Each needs a written design before code, because +each changes who can see what. + +- 💭 **A Binder link for day-of helpers without an account.** It would carry + phone numbers, so it needs its own look at what a link may reveal. +- 💭 **Agency teams.** More than one planner on a wedding. +- 💭 **Editing on phones.** Today phones get the read-only Binder, and the + editing tools are desktop. +- 💭 **Public calculator pages.** For example, a standalone drinks calculator + that people find through search. +- 💭 **A keyboard shortcut for the toolbox.** +- 💭 **More ways in.** Importers for other planners' exports, and tools + nobody has thought of yet. Build one with + [docs/BUILDING-A-TOOL.md](docs/BUILDING-A-TOOL.md). + +--- + +## 🚫 Not planned, on purpose + +So nobody spends a weekend on something that will be declined: + +- **A paid tier, premium features or upsells.** This is the reason the + project exists. +- **RSVP collection.** Joy and similar services do this well. Trousseau + imports the result and never unseats or silently deletes a guest. +- **An admin panel or support login** that can browse weddings. +- **An official Docker image**, for now. The app is one Node process and an + optional Supabase project. If a container would make your setup genuinely + simpler, open a discussion and make the case. + +--- + +## Picking something up + +1. Find the item's issue, or open one naming the row above. +2. Say you are taking it, and sketch the approach in a few lines. +3. Read [CONTRIBUTING.md](CONTRIBUTING.md) and, for anything that crosses + tools, [docs/BUILDING-A-TOOL.md](docs/BUILDING-A-TOOL.md) section on + connecting tools. diff --git a/docs/PRODUCT-ROADMAP.md b/docs/PRODUCT-ROADMAP.md index a95edaf..98a9b39 100644 --- a/docs/PRODUCT-ROADMAP.md +++ b/docs/PRODUCT-ROADMAP.md @@ -44,7 +44,7 @@ instead (see subsystem F). | E | Brigade's expanded scope | (loosely) A, B | ✅ **built** — [spec](superpowers/specs/2026-09-08-brigade-vendors-budget-tasks-design.md), [plan](superpowers/plans/2026-09-08-brigade-vendors-budget-tasks.md) complete 2026-09-08. E4 is a confirmation date, not a portal | | F | Onboarding, billing & legal at product scale | A | ✅ **built** — [spec](superpowers/specs/2026-09-02-onboarding-billing-legal-design.md), [plan](superpowers/plans/2026-09-07-licensing-and-self-hosting.md) complete 2026-09-07; privacy/terms rewritten 2026-09-08 | | H | Guided tour & example wedding | — | ✅ **built** — [spec](superpowers/specs/2026-09-07-guided-tour-design.md), [plan](superpowers/plans/2026-09-07-guided-tour.md) complete 2026-09-07 | -| I | Retention sweep for account weddings | B | ⬜ **not started** — the 24-month sweep covers synced weddings only; the privacy page deliberately does not claim it for accounts | +| I | Retention sweep for account weddings | B | ✅ **built** — `app/api/cron/sweep` deletes account weddings unwritten for 24 months (`lib/documents/retention.ts`), and the Privacy Policy states it | | G | Multi-tenant suite mechanics | A, B | ✅ **built** — [spec](superpowers/specs/2026-09-02-multitenant-mechanics-design.md), [plan](superpowers/plans/2026-09-07-multitenant-mechanics.md) complete 2026-09-07 | | J | Planner role and many weddings per account | A, G | ⬜ planned — [master plan](superpowers/specs/2026-09-28-expansion-master-plan.md), phases 1 and 3 | | K | Setup flow and signing in safely | A, B | ⬜ planned — master plan, phase 1 | diff --git a/marketing/STRATEGY.md b/marketing/STRATEGY.md new file mode 100644 index 0000000..58a0cbd --- /dev/null +++ b/marketing/STRATEGY.md @@ -0,0 +1,304 @@ +# Trousseau — growth strategy + +The plan for getting Trousseau in front of the people it is for. Every claim in +this folder was checked against the code before it was written. The last +section lists the claims that were **left out** because the code does not back +them. Read it before editing any of the copy. + +--- + +## One-line positioning + +> **Trousseau is a free, open-source wedding planner where every tool shares +> one wedding: seat your guests and one click puts every table number on the +> place cards; move the ceremony and the whole day moves with it.** + +## Unique selling proposition + +**100% free, private, self-hostable and modular. No ads, no upsells, no data +sales, and no paid tier, ever.** + +Each part of that is backed by the repository: + +| Claim | Evidence | +| --- | --- | +| Free forever, no paid tier | `docs/PRODUCT-ROADMAP.md`: "no paywalled tiers, no upsells on someone's wedding … it rules out billing infrastructure as a subsystem entirely." The AGPL on `suite/` is there to stop a paid closed fork. | +| Private | With no account, the wedding lives in the browser's IndexedDB and nothing leaves the device. There is no admin panel or support login. Guest data never goes to a third party (roadmap decision, 2026-09-28). | +| Self-hostable | `docs/SELF-HOSTING.md` is a tested runbook. Local-only needs no backend at all. Accounts and sync need a Supabase project. | +| Modular | `suite/lib/tools.ts`: five tools are on by default and six more can be added from the toolbox. Removing a tool hides it without deleting its work. | +| No tracking when self-hosted | Page counting renders only when `onVercel()` (`suite/app/layout.tsx`), and Sentry only with a DSN set. A self-hosted instance off Vercel sends nothing. | +| Your data, portable | *Download my wedding* exports the whole thing as one `.trousseau.json` file, the same format the app uses. The schema is an MIT-licensed npm package (`@jfrusher/trousseau`). | + +## The idea that makes it different + +Most wedding apps are a set of separate pages that happen to share a login. +Trousseau is **one document with one owner per slice**. Each tool rewrites +only its own part of the wedding and reads everyone else's. This is the +mechanism behind every demo worth showing: + +- **Seating → Place cards.** Seat someone, press *Use the room*, and every + card already has its table number. +- **Timeline → Delegation.** Pin the ceremony and let the rest of the day + follow it. Move the ceremony by ten minutes and every job hanging off it + moves too. +- **Seating → Timeline.** A block's Location offers the spaces drawn on the + floor plan, so "Orangery" means the same room in both. +- **Checks between tools.** It flags two dates for one wedding, one seat + holding two people, a table over capacity, and confirmed guests with no + table or no dietary answer. + +Lead with this. "Free wedding planner" is a crowded phrase. "The tools +agree with each other" is not. + +--- + +## Core features (from the code) + +Extracted from `suite/lib/tools.ts`, the app routes and the tool modules. + +**Always on** + +- 👥 **Guests.** The one guest list every tool builds on. It has a CSV import + with a column mapper that guesses fields (name, email, RSVP status, + dietary, main course, side, notes) and shows a preview before anything is + written. Exports from Joy, Zola, The Knot or a spreadsheet all import. + (`suite/lib/data/guestImport.ts`) + +**On by default** + +- 🪑 **Seating.** Draw the room to scale in real units. Drag tables and + guests, with alignment snapping. Groups, families, and "keep together / + keep apart" rules. A live dietary breakdown. A printable floor plan. +- 💌 **Place cards.** Print-ready cards and table signs. Bind + `{{First Name}}`, `{{Table}}` and other fields onto your own artwork. + Export refuses to print a missing font or an empty monogram. +- 🕒 **Timeline.** Lanes for the day, suppliers and transport. Blocks are + either pinned to a clock time or follow what comes before. A + scheduling resolver squeezes flexible blocks to make a fixed time. It + flags collisions and curfew overruns, and checks travel time between + places. Sunset and golden hour are computed offline. Calendar files. +- 📋 **Delegation.** Jobs hung off each block of the day, and who is doing + them. People are picked from the guest list, not retyped. +- 📷 **Group shots.** The family photo list, built from who is related to + whom. + +**Added from the toolbox** + +- 💍 **Ceremony.** The processional, the order of service, music and + readings, and cues shown on the Timeline. +- 📦 **Boxes.** What is packed in which box, and where each box has to be by + when. Labels, a packing list and a spreadsheet. +- 🍷 **Bar.** How much drink to buy, in bottles and cases (UK units), what it + roughly costs, and a shopping list. +- 💷 **Money.** Supplier costs against your budget, what is paid, and what + falls due. +- ✅ **Checklist.** What to have done before the day, each item with a date. +- 📱 **Binder.** The day on your phone, read-only: what is on now and next, + who to ring, where a guest sits. Works without signal. + +**Around the tools** + +- 🖨️ **The pack.** One PDF with the floor plan, run sheet, job list and group + shots. +- 🔗 **Guest seat links.** One guest sees their own seat and nothing else. + **Supplier links** let a supplier see and confirm their part. +- 🤝 **Two partners and a planner.** Magic-link sign-in with no passwords. + Real-time sync and presence. Conflicts are shown, never silently resolved. + Version history with restore. +- 🗂️ **Planner mode.** Many weddings per account, and a library of reusable + processionals, box sets and bar settings. +- 🧭 **Guided tour** with an example wedding (100 guests, 27 day blocks), and + a ⌘/Ctrl-K command palette. + +--- + +## Target audiences + +### A) DIY and budget-conscious couples + +**Who:** couples planning their own wedding, often with a venue and caterer +but no paid planner. They live in spreadsheets, Pinterest and group chats. + +**Pain:** the "free" planning apps are paid for some other way: vendor +marketplaces, registry commissions, ads and upsells to printed goods. The +tools also do not talk to each other. The seating chart, the place cards and +the run sheet are three copies of the guest list that drift apart. + +**What we say:** + +- Free. Not free-trial, not freemium. There is no paid version to be upsold + to. +- No sign-up to start. Open it and plan. Your guest list stays on your device + unless you choose to sync. +- Print your own place cards with the table numbers already filled in. +- Import the guest list you already have, from Joy, Zola, The Knot or a + spreadsheet. + +**On savings:** the savings are real but specific. Keep the claim to things +the product replaces: + +1. **Seating chart software.** Some is paid; this is not. +2. **Place card and table sign printing.** You print at home with numbers + bound from the seating plan, instead of paying per card for a stationer + to typeset names. +3. **Planner-grade run sheets and call sheets.** People otherwise pay for + these as subscription software or as a coordinator's time. + +The brief suggested "save $300–$1,000+". That figure is **not verified**, and +the planning tools of Zola, The Knot and Joy are free to use. Do not publish a +dollar figure until someone has priced real alternatives, with sources and a +date. What we *can* say without a source: "no paid tier, no upsell, no +per-guest pricing." + +**Where they are:** r/WeddingsUnder10k, r/weddingplanning, r/Weddingsunder5k, +UK wedding forums (Trousseau is UK-built: bar units, "licence"), wedding +TikTok and Instagram, Pinterest. + +### B) Self-hosters and tech-savvy users + +**Who:** people who run Jellyfin, Immich or Home Assistant, and who are now +planning a wedding (theirs, a sibling's, a friend's). + +**Pain:** a guest list is a spreadsheet of names, emails, dietary needs +(which can be health data) and family relationships. It sits on a platform +whose business is vendor leads. + +**What we say:** + +- Local-first. With no account, the wedding lives in IndexedDB and nothing + leaves the device. +- Self-host it with your own domain and your own Supabase (accounts and sync + are optional). +- **No analytics off Vercel.** The page counter renders only on Vercel; + Sentry only with a DSN you set. +- Export everything as one JSON file whose schema is an MIT-licensed npm + package. +- AGPL, so nobody can take it closed. + +**Be upfront about what is not there:** there is **no Docker image**, and that +is deliberate (see `docs/SELF-HOSTING.md`). It is a Next.js app plus an +optional Supabase project. r/selfhosted will ask about this in the first +five comments. The copy answers it before they ask, and invites the +discussion instead of promising an image. + +**Where they are:** r/selfhosted, r/homelab, Hacker News, Lobsters, the +Fediverse (Mastodon: #selfhosted, #opensource), awesome-selfhosted. + +### C) Open-source contributors + +**Who:** TypeScript and React developers looking for a well-kept project with +a real user base and a clear path to a first PR. + +**What we say:** + +- Modern stack: Next.js 16, React 19, TypeScript, Zustand, zod 4, Tailwind 4, + Supabase (Postgres + RLS), Vitest, Playwright with axe. +- A test suite that means it: over 1,800 test cases across 230+ test files, + plus end-to-end runs against the production build. RLS is tested against + PGlite. +- Decisions written down. Every subsystem has a dated spec and plan in + `docs/superpowers/`, so you can tell whether an idea fits before writing + code. +- A guide to adding a whole tool (`docs/BUILDING-A-TOOL.md`), and a documented + data contract that preserves keys a tool does not know about. +- Built in the open with Claude Code. Around half the commits are co-authored + with it. That is a story worth telling honestly (see the HN copy). + +**Where they are:** Hacker News, r/opensource, r/webdev, r/nextjs, r/reactjs, +dev.to, Hashnode, GitHub topics, "good first issue" aggregators (goodfirstissue.dev, +up-for-grabs.net). + +--- + +## Channels and sequencing + +| Week | Channel | Asset | Goal | +| --- | --- | --- | --- | +| 0 | GitHub | New README, CONTRIBUTING, ROADMAP, repo topics, social preview image, 5–10 `good first issue` labels | Convert the traffic that is about to arrive | +| 1 (Tue–Thu, 8–10am US Eastern) | Hacker News | `copy/hacker-news.md` | Stars, technical feedback | +| 1 (+1 day) | r/selfhosted | `copy/reddit-posts.md` §1 | Self-host installs, Docker discussion | +| 1 (+2 days) | Twitter/X + LinkedIn | `copy/social-media.md` thread | Builders' audience, Claude Code angle | +| 2 | r/opensource or r/webdev | `copy/reddit-posts.md` §3 | Contributors | +| 2 | r/WeddingsUnder10k | `copy/reddit-posts.md` §2 | Couples using the hosted app | +| 2–6 | TikTok, Reels, Shorts | `copy/social-media.md` video scripts | Couples, long tail | +| Ongoing | Blog (`suite/app/blog`) | Guides for couples who search | SEO | + +**Rules for every post:** + +1. Read each subreddit's current self-promotion rules on the day. They change. +2. Post from the maintainer's own account, in the first person, and stay in + the thread for the first three hours. +3. Never post a real guest list or real names in a screenshot. Use the + tour's example wedding. +4. Spread posts over days. Posting the same link to five communities in an + hour reads as spam and gets removed. + +## Before launch: checklist + +- [ ] Add repo topics: `wedding`, `wedding-planner`, `seating-chart`, + `self-hosted`, `local-first`, `nextjs`, `supabase`, `typescript`, + `open-source`. +- [ ] Set the repository social preview image (Settings → General) to + `marketing/assets/images/social-preview.png` (1280×640). +- [x] Record the GIFs and put them in the README (see "Assets" below). +- [ ] Open 5–10 issues labelled `good first issue`. Candidates: the tool + proposals in `ROADMAP.md`, one per issue. +- [ ] Enable GitHub Discussions, so questions do not become issues. The + README, ROADMAP and landing page link to it for the Docker question. +- [ ] Enable **private vulnerability reporting** (Settings → Code security). + `CONTRIBUTING.md` sends security reports there. +- [ ] Publish `marketing/landing-page/` (GitHub Pages from the repo root, or a + Vercel project) and put its URL in the repo's "About" box. The page + loads its images and clips from `../assets/`. If you host the folder on + its own, copy those files beside it and change the references. +- [ ] Decide whether to archive the four standalone predecessor repos + (roadmap subsystem C) so search traffic lands here. + +## Assets + +All captured from the guided tour's example wedding, never a real one. The +full index, and how to regenerate everything when the UI changes, is in +[`assets/README.md`](assets/README.md). + +| Asset | Shows | Used in | +| --- | --- | --- | +| `motion/seat-to-card.{gif,mp4}` | Zainab dragged to Table 13, *Use the room*, her card reads "Table 13" | README, HN, thread 1/7, video 1 | +| `motion/ceremony-moves.{gif,mp4}` | Ceremony 13:30 → 14:00, the day follows; 14:30 flags a collision | README, thread 3/7, video 2 | +| `motion/binder.{gif,mp4}` | The Binder on a phone: now, day, ring, find, shots | README, Reddit couples post, video 3 | +| `images/social-preview.png` (1280×640) | Headline and Seating | GitHub social preview | +| `images/og-card.png` (1200×630) | The same, at link-card size | Landing page, link unfurls | +| `images/hero-*.png` (1600×1000) | One per tool, headline over the app | Blog, landing page, LinkedIn | +| `images/square-*.png`, `pledge.png` (1080×1080) | Carousel cards | Instagram, LinkedIn | +| `images/story.png` (1080×1920) | Vertical cover | Stories, Shorts and Reels covers | +| `images/tools-grid.png`, `binder-trio.png` | All eleven tools; three phones | README, HN, Reddit | + +## Metrics + +| Metric | Source | Week 1 target | Week 6 target | +| --- | --- | --- | --- | +| GitHub stars | GitHub | Record the baseline on launch day, then set targets | — | +| Hosted visits | Vercel Web Analytics (route-only, cookieless) | Baseline | — | +| Weddings created | Supabase `account_weddings` row count (aggregate only) | Baseline | — | +| Self-host signals | Issues and discussions mentioning self-hosting, forks | — | — | +| First-time contributors | Merged PRs from new authors | 1 | 5 | + +Targets are left blank on purpose. Set them after launch-day baselines, not +before. + +--- + +## Claims deliberately left out + +These appeared in the brief but the repository contradicts them. **Do not add +them back without changing the code first.** + +| Claim | Why it is out | What to say instead | +| --- | --- | --- | +| "Docker Compose / Deploy on Docker" | No Dockerfile or compose file exists. `docs/SELF-HOSTING.md` says there is no Docker image, on purpose. | "Runs anywhere Next.js runs. Local-only needs no backend; sync needs Supabase." A **Deploy with Vercel** button is real (the hosted instance runs on Vercel with root `suite`). | +| "RSVP tracking" | RSVP collection is explicitly deferred: "Joy and similar do it; Trousseau imports the result." | "Import your RSVPs from Joy, Zola or The Knot. Confirmed guests with no table are flagged." | +| "Menu selector" | There is no menu builder. Guests carry a main-course choice from the import, and dietary requirements are broken down. | "Dietary needs and main-course choices come in with your guest list and are counted as you seat people." | +| "Automatic seating algorithm" | Seating is manual, to scale, with rules (together/apart) and warnings. There is no solver. | "Keep-together and keep-apart rules, with warnings when you break them." The algorithmic story is the **Timeline resolver**. | +| "Zero tracking" (for the hosted site) | The hosted site counts page visits with Vercel Web Analytics (no cookie, and addresses cut to the route). | "No ads, no cookies, no data sales. The hosted site counts page visits, not people. Self-hosted, it counts nothing." | +| "The host can't read your guest link" | `wedding_shares.share_key` is stored in the database for members. The operator could decrypt. | "The key travels after the `#`, so it never appears in a request. The public endpoint only ever returns ciphertext." | +| "End-to-end encrypted" | Wedding data is encrypted at rest with RLS, not end-to-end. The README and Privacy Policy say so. | "Encrypted at rest; database rules stop any other account reading it; no admin panel." | +| "$300–$1,000+ saved" | Unverified. The big platforms' planning tools are free to use. | See "On savings" above. | diff --git a/marketing/assets/README.md b/marketing/assets/README.md new file mode 100644 index 0000000..d48dabd --- /dev/null +++ b/marketing/assets/README.md @@ -0,0 +1,114 @@ +# Marketing assets + +Screenshots, framed images and short clips of Trousseau, all captured from +the real app running the guided tour's example wedding (Alex & Sam, The Old +Granary, 1 June 2028). No real guest appears anywhere. + +Everything here is generated by the scripts in [`pipeline/`](pipeline/), so +when the UI changes, the whole set can be made again in about fifteen +minutes (see "Regenerating" below). + +--- + +## Motion — `motion/` + +Each clip comes as a **GIF** (for the README, where GitHub will not play a +video from the repository) and an **MP4** (for social posts and the landing +page: smaller and sharper). + +| Clip | Length | What happens | Use it for | +| --- | --- | --- | --- | +| `seat-to-card` | 17s | Zainab is found in Seating and dragged onto Table 13. In Place cards, *Use the room* takes the guest list from the room, and her card reads "Table 13". | README, Hacker News, thread 1/7, video script 1 | +| `ceremony-moves` | 17s | The ceremony, pinned at 13:30, is moved to 14:00: drinks, photos and dinner move with it. Moved to 14:30, the Timeline flags "Cake cutting overruns into First dance". | README, thread 3/7, video script 2 | +| `binder` | 20s | The Binder on a phone at 13:45 on the day: now, the running order, who to ring, finding a guest's table, ticking off the shot list. 4:5, in a phone frame. | README, Reddit couples post, Instagram, video script 3 | + +Desktop clips zoom in on whatever is happening and carry a caption for each +step, so they read on a phone screen without sound. + +## Images — `images/` + +| File | Size | Use it for | +| --- | --- | --- | +| `social-preview.png` | 1280×640 | GitHub → Settings → General → Social preview | +| `og-card.png` | 1200×630 | Link unfurls: the landing page's `og:image`, Slack, X, LinkedIn | +| `hero-overview.png` | 1600×1000 | The README's top image; a blog or launch-post header | +| `hero-.png` | 1600×1000 | One per tool (guests, seating, place-cards, timeline, delegation, group-shots, ceremony, boxes, bar, money, checklist): headline and a line of copy over the app | +| `tools-grid.png` | 1600×1000 | "Every tool. One wedding." The front page, the guest list and every desktop tool at a glance | +| `binder-trio.png` | 1600×1000 | Three phones: who to ring, what's on now, find a guest | +| `square-.png` | 1080×1080 | A carousel for Instagram or LinkedIn: overview, seating, place-cards, timeline, ceremony, money | +| `pledge.png` | 1080×1080 | The privacy pledge, as a card: last slide of a carousel | +| `story.png` | 1080×1920 | Instagram and Facebook stories; a cover for Shorts, Reels and TikTok | + +## Screenshots — `screenshots/` + +The raw captures every image above is built from, at 2× (2880×1800 for +desktop, 1170×2532 for the phone), in case you want to lay out something of +your own. One per page (overview, guests, seating, place-cards, timeline, +delegation, group-shots, ceremony, boxes, bar, money, checklist), plus the +toolbox, the ⌘K command palette, a selected ceremony block, and five Binder +views. + +--- + +## Regenerating + +### What you need + +- The suite built and running on port 3100: + ```sh + npm ci && npm run build && npm run build -w suite + cd suite && npx next start -p 3100 + ``` +- Python 3 with `pip install Pillow imageio-ffmpeg` (a full ffmpeg with the + GIF palette filters and x264). +- Playwright's Chromium, as the e2e suite already uses. + +### Running it + +From `marketing/assets/pipeline/`: + +```sh +node stills.mjs # → ../screenshots/ + +node scene-seat-to-card.mjs # records frames into work/ +python3 edit.py seat-to-card 1600 1000 # zoom-follow and captions +python3 encode.py seat-to-card-edit 960 1600 # → ../motion/seat-to-card.{gif,mp4} + +node scene-ceremony-moves.mjs +python3 edit.py ceremony-moves 1600 1000 +python3 encode.py ceremony-moves-edit 960 1600 + +node scene-binder.mjs +python3 phone.py binder # into a phone frame, 4:5 +python3 encode.py binder-phone 720 1080 # → ../motion/binder.{gif,mp4} + +python3 compose.py && node render.mjs # → ../images/ + +# Optional, and not committed (10 MB each): slideshow videos of the stills. +python3 montage.py tour 1280 800 hero-overview hero-guests hero-seating hero-place-cards hero-timeline hero-delegation hero-group-shots hero-ceremony hero-boxes hero-bar hero-money hero-checklist +CRF=29 python3 montage.py tour-square 1080 1080 square-overview square-seating square-place-cards square-timeline square-ceremony square-money pledge +``` + +`work/` holds the frames and is ignored by git. + +### How it works, briefly + +- **Stills** are taken at 2× in `en-GB`, with the browser's clock set to 18 + April 2028, so the front page reads "44 days to go" and its next step is + seating three guests rather than an overdue bill. The Binder's clock is + 13:45 on the day, so the ceremony is "now". +- **Clips are recorded frame by frame**, not screen-recorded: the script + moves the pointer in eased steps and takes a screenshot after each one, so + every frame is sharp and the timing is exact. Headless Chromium draws no + pointer, so one is drawn into the page, with a ring on each click (a + fingertip on the phone). +- **The camera** is a list of targets the scene names as it goes: the + elements themselves, measured on screen, so a moved button does not break + the framing. `edit.py` eases between them and adds the captions. +- **Framed images** are HTML pages in the app's own typefaces and colours + (`suite/public/fonts`, `suite/lib/design/tokens.css`), screenshotted by + Chromium at their exact size. + +If a scene fails after a UI change, the error names the locator it was +waiting for. The scenes use the same roles and labels as the e2e specs in +`suite/e2e/`, so a change that breaks one usually breaks the other. diff --git a/marketing/assets/images/binder-trio.png b/marketing/assets/images/binder-trio.png new file mode 100644 index 0000000..91e7a16 Binary files /dev/null and b/marketing/assets/images/binder-trio.png differ diff --git a/marketing/assets/images/hero-bar.png b/marketing/assets/images/hero-bar.png new file mode 100644 index 0000000..bf68933 Binary files /dev/null and b/marketing/assets/images/hero-bar.png differ diff --git a/marketing/assets/images/hero-boxes.png b/marketing/assets/images/hero-boxes.png new file mode 100644 index 0000000..b569f11 Binary files /dev/null and b/marketing/assets/images/hero-boxes.png differ diff --git a/marketing/assets/images/hero-ceremony.png b/marketing/assets/images/hero-ceremony.png new file mode 100644 index 0000000..14d13d8 Binary files /dev/null and b/marketing/assets/images/hero-ceremony.png differ diff --git a/marketing/assets/images/hero-checklist.png b/marketing/assets/images/hero-checklist.png new file mode 100644 index 0000000..81dbe7a Binary files /dev/null and b/marketing/assets/images/hero-checklist.png differ diff --git a/marketing/assets/images/hero-delegation.png b/marketing/assets/images/hero-delegation.png new file mode 100644 index 0000000..d0574e3 Binary files /dev/null and b/marketing/assets/images/hero-delegation.png differ diff --git a/marketing/assets/images/hero-group-shots.png b/marketing/assets/images/hero-group-shots.png new file mode 100644 index 0000000..67f7fb5 Binary files /dev/null and b/marketing/assets/images/hero-group-shots.png differ diff --git a/marketing/assets/images/hero-guests.png b/marketing/assets/images/hero-guests.png new file mode 100644 index 0000000..df3d7eb Binary files /dev/null and b/marketing/assets/images/hero-guests.png differ diff --git a/marketing/assets/images/hero-money.png b/marketing/assets/images/hero-money.png new file mode 100644 index 0000000..f8cac3d Binary files /dev/null and b/marketing/assets/images/hero-money.png differ diff --git a/marketing/assets/images/hero-overview.png b/marketing/assets/images/hero-overview.png new file mode 100644 index 0000000..886dbf9 Binary files /dev/null and b/marketing/assets/images/hero-overview.png differ diff --git a/marketing/assets/images/hero-place-cards.png b/marketing/assets/images/hero-place-cards.png new file mode 100644 index 0000000..46b9367 Binary files /dev/null and b/marketing/assets/images/hero-place-cards.png differ diff --git a/marketing/assets/images/hero-seating.png b/marketing/assets/images/hero-seating.png new file mode 100644 index 0000000..ea9c721 Binary files /dev/null and b/marketing/assets/images/hero-seating.png differ diff --git a/marketing/assets/images/hero-timeline.png b/marketing/assets/images/hero-timeline.png new file mode 100644 index 0000000..9c1b2f5 Binary files /dev/null and b/marketing/assets/images/hero-timeline.png differ diff --git a/marketing/assets/images/og-card.png b/marketing/assets/images/og-card.png new file mode 100644 index 0000000..a577d04 Binary files /dev/null and b/marketing/assets/images/og-card.png differ diff --git a/marketing/assets/images/pledge.png b/marketing/assets/images/pledge.png new file mode 100644 index 0000000..5fe6595 Binary files /dev/null and b/marketing/assets/images/pledge.png differ diff --git a/marketing/assets/images/social-preview.png b/marketing/assets/images/social-preview.png new file mode 100644 index 0000000..1b1a21d Binary files /dev/null and b/marketing/assets/images/social-preview.png differ diff --git a/marketing/assets/images/square-ceremony.png b/marketing/assets/images/square-ceremony.png new file mode 100644 index 0000000..58d2e38 Binary files /dev/null and b/marketing/assets/images/square-ceremony.png differ diff --git a/marketing/assets/images/square-money.png b/marketing/assets/images/square-money.png new file mode 100644 index 0000000..ff8e895 Binary files /dev/null and b/marketing/assets/images/square-money.png differ diff --git a/marketing/assets/images/square-overview.png b/marketing/assets/images/square-overview.png new file mode 100644 index 0000000..6bf7d2e Binary files /dev/null and b/marketing/assets/images/square-overview.png differ diff --git a/marketing/assets/images/square-place-cards.png b/marketing/assets/images/square-place-cards.png new file mode 100644 index 0000000..f026186 Binary files /dev/null and b/marketing/assets/images/square-place-cards.png differ diff --git a/marketing/assets/images/square-seating.png b/marketing/assets/images/square-seating.png new file mode 100644 index 0000000..f80ac3f Binary files /dev/null and b/marketing/assets/images/square-seating.png differ diff --git a/marketing/assets/images/square-timeline.png b/marketing/assets/images/square-timeline.png new file mode 100644 index 0000000..9156cc3 Binary files /dev/null and b/marketing/assets/images/square-timeline.png differ diff --git a/marketing/assets/images/story.png b/marketing/assets/images/story.png new file mode 100644 index 0000000..e4833d4 Binary files /dev/null and b/marketing/assets/images/story.png differ diff --git a/marketing/assets/images/tools-grid.png b/marketing/assets/images/tools-grid.png new file mode 100644 index 0000000..4c8c469 Binary files /dev/null and b/marketing/assets/images/tools-grid.png differ diff --git a/marketing/assets/motion/binder.gif b/marketing/assets/motion/binder.gif new file mode 100644 index 0000000..9e0a2b7 Binary files /dev/null and b/marketing/assets/motion/binder.gif differ diff --git a/marketing/assets/motion/binder.mp4 b/marketing/assets/motion/binder.mp4 new file mode 100644 index 0000000..0f3aed8 Binary files /dev/null and b/marketing/assets/motion/binder.mp4 differ diff --git a/marketing/assets/motion/ceremony-moves.gif b/marketing/assets/motion/ceremony-moves.gif new file mode 100644 index 0000000..c80ea3a Binary files /dev/null and b/marketing/assets/motion/ceremony-moves.gif differ diff --git a/marketing/assets/motion/ceremony-moves.mp4 b/marketing/assets/motion/ceremony-moves.mp4 new file mode 100644 index 0000000..eb9d36b Binary files /dev/null and b/marketing/assets/motion/ceremony-moves.mp4 differ diff --git a/marketing/assets/motion/seat-to-card.gif b/marketing/assets/motion/seat-to-card.gif new file mode 100644 index 0000000..73ff874 Binary files /dev/null and b/marketing/assets/motion/seat-to-card.gif differ diff --git a/marketing/assets/motion/seat-to-card.mp4 b/marketing/assets/motion/seat-to-card.mp4 new file mode 100644 index 0000000..21fdc14 Binary files /dev/null and b/marketing/assets/motion/seat-to-card.mp4 differ diff --git a/marketing/assets/pipeline/.gitignore b/marketing/assets/pipeline/.gitignore new file mode 100644 index 0000000..e958bd4 --- /dev/null +++ b/marketing/assets/pipeline/.gitignore @@ -0,0 +1,2 @@ +work/ +__pycache__/ diff --git a/marketing/assets/pipeline/compose.py b/marketing/assets/pipeline/compose.py new file mode 100644 index 0000000..a3eb37e --- /dev/null +++ b/marketing/assets/pipeline/compose.py @@ -0,0 +1,153 @@ +"""Every framed marketing image as an HTML page, plus a manifest of sizes to render.""" +import json, os +HERE = os.path.dirname(os.path.abspath(__file__)) +RAW = os.path.join(HERE, "../screenshots"); FONTS = os.path.join(HERE, "../../../suite/public/fonts"); OUT = os.path.join(HERE, "work/compose") + +TOKENS = { # lib/design/tokens.css + "ground": "#fdfbf7", "stone": "#f4f1ea", "canvas": "#e9e4da", "border": "#d8d2c6", "muted": "#6b6561", "ink": "#1c1917", + "sage": "#2f6d5f", "brass": "#80632f", "brass-bright": "#ac8a55", "moss": "#566f59", "taupe": "#736755", "slate": "#46617a", "blush": "#f3e3dc", +} +BASE_CSS = f""" +@font-face {{ font-family: Marcellus; src: url(file://{FONTS}/Marcellus-Regular.ttf); }} +@font-face {{ font-family: Lato; src: url(file://{FONTS}/Lato-Regular.ttf); }} +@font-face {{ font-family: Crimson; src: url(file://{FONTS}/CrimsonText-Regular.ttf); }} +* {{ box-sizing: border-box; margin: 0; }} +html, body {{ width: 100%; height: 100%; }} +body {{ font-family: Lato, sans-serif; color: {TOKENS['ink']}; background: {TOKENS['ground']}; overflow: hidden; -webkit-font-smoothing: antialiased; }} +.bg {{ position: absolute; inset: 0; background: + radial-gradient(1200px 700px at 85% -10%, var(--tint, #f1e8d7) 0%, transparent 60%), + radial-gradient(900px 600px at -10% 110%, #e6ede6 0%, transparent 55%), + linear-gradient(180deg, {TOKENS['ground']} 0%, {TOKENS['stone']} 100%); }} +.grain {{ position: absolute; inset: 0; opacity: .035; background-image: radial-gradient({TOKENS['ink']} 1px, transparent 1px); background-size: 3px 3px; }} +h1 {{ font-family: Marcellus, serif; font-weight: 400; letter-spacing: -0.01em; line-height: 1.05; }} +.eyebrow {{ font-size: 15px; letter-spacing: .22em; text-transform: uppercase; color: var(--accent, {TOKENS['brass']}); font-weight: 400; }} +.sub {{ color: {TOKENS['muted']}; line-height: 1.45; }} +.window {{ position: absolute; border-radius: 14px; overflow: hidden; background: #fff; + box-shadow: 0 1px 0 rgba(255,255,255,.6) inset, 0 40px 80px -20px rgba(28,25,23,.35), 0 18px 36px -18px rgba(28,25,23,.3), 0 0 0 1px rgba(28,25,23,.08); }} +.chrome {{ height: 40px; background: linear-gradient(#f7f4ee, #efebe3); border-bottom: 1px solid {TOKENS['border']}; display: flex; align-items: center; padding: 0 16px; gap: 8px; }} +.dot {{ width: 12px; height: 12px; border-radius: 50%; }} +.url {{ margin: 0 auto; transform: translateX(-26px); background: #fff; border: 1px solid {TOKENS['border']}; border-radius: 8px; padding: 5px 16px; font-size: 13px; color: {TOKENS['muted']}; min-width: 340px; text-align: center; }} +.shot {{ display: block; width: 100%; }} +.crop {{ background-repeat: no-repeat; }} +.pill {{ display: inline-flex; align-items: center; gap: 8px; border-radius: 999px; padding: 8px 16px; font-size: 16px; background: #fff; border: 1px solid {TOKENS['border']}; }} +.brand {{ font-family: Marcellus, serif; font-size: 26px; display: flex; align-items: center; gap: 10px; }} +""" + +def page(name, w, h, body, tint="#f1e8d7", accent=TOKENS["brass"], scale=1): + html = f"
{body}" + open(f"{OUT}/{name}.html", "w").write(html) + manifest.append({"name": name, "width": w, "height": h, "scale": scale}) + +def window(src, x, y, w, path, crop=None): + """A browser window showing a raw capture (1440x900 CSS), optionally cropped to a CSS rect.""" + if crop: + cx, cy, cw, ch = crop; k = w / cw; ih = ch * k + inner = f"
" + else: + inner = f"" + return (f"
" + f"" + f"trousseau-suite.vercel.app{path}
{inner}
") + +manifest = [] +os.makedirs(OUT, exist_ok=True) + +# ---------- Heroes: 1600x1000, headline over a browser window ---------- +HEROES = [ + ("overview", "/", "One wedding, one place", "The whole wedding, in one place.", "Eleven planning tools that share one guest list, one room and one day. Free, private, open source.", TOKENS["brass"], "#f1e8d7"), + ("guests", "/guests", "Guests", "One guest list. Every tool reads it.", "Import from Joy, Zola, The Knot or any spreadsheet — with a preview before anything is saved.", TOKENS["taupe"], "#eeeae3"), + ("seating", "/seating", "Seating", "Draw the room. Then put people in it.", "To scale, in real units. Keep-together and keep-apart rules, and a dietary count as you go.", TOKENS["taupe"], "#eeeae3"), + ("place-cards", "/place-cards", "Place cards", "Place cards, straight from the room.", "Your design, every table number filled in, 100 cards on 13 print-ready sheets.", TOKENS["sage"], "#e4efec"), + ("timeline-ceremony-selected", "/timeline", "Timeline", "Pin the ceremony. The day follows.", "Collisions, curfew, travel time between places — and golden hour for the photos.", TOKENS["brass"], "#f1e8d7"), + ("delegation", "/delegation", "Delegation", "Every job has a name next to it.", "Hung off the day itself, so a job moves when its part of the day does. A sheet for each person.", TOKENS["moss"], "#e6ede6"), + ("group-shots", "/group-shots", "Group shots", "The family photo list, built from who's who.", "26 shots for the photographer, and a copy on your phone to tick off on the day.", TOKENS["slate"], "#e7ecf0"), + ("ceremony", "/ceremony", "Ceremony", "The order of service, to the minute.", "Who walks when, and to what. Readings, music, and what the law asks for.", TOKENS["slate"], "#e7ecf0"), + ("boxes", "/boxes", "Boxes", "What's in which box, and where it has to be.", "Labels, a packing list, and a person to take each one.", TOKENS["moss"], "#e6ede6"), + ("bar", "/bar", "Bar", "How much drink to buy.", "In bottles and cases, worked out from your guest list and your day.", TOKENS["moss"], "#e6ede6"), + ("money", "/money", "Money", "Every supplier, every payment, one budget.", "What's booked, what's paid, and what falls due next.", TOKENS["moss"], "#e6ede6"), + ("checklist", "/checklist", "Checklist", "What to do before the day, each with a date.", "What's late, what's due in the next 30 days, and what can wait.", TOKENS["moss"], "#e6ede6"), +] +for src, path, eyebrow, head, sub, accent, tint in HEROES: + body = (f"
" + f"
{eyebrow}
" + f"

{head}

" + f"

{sub}

" + + window(src, 150, 300, 1300, path)) + page(f"hero-{src.replace('-ceremony-selected','')}", 1600, 1000, body, tint, accent) + +# ---------- Social preview (GitHub, 1280x640) and link card (1200x630) ---------- +def social(name, w, h): + body = (f"
" + f"
💍 Trousseau
" + f"

Plan the whole wedding in one place.

" + f"

Seating, place cards, the day, the jobs and the money — sharing one wedding.

" + f"
Free foreverOpen sourceNo sign-up
" + + window("seating", 590, 90, 900, "/seating")) + page(name, w, h, body) +social("social-preview", 1280, 640) +social("og-card", 1200, 630) + +# ---------- Square carousel cards, 1080x1080: a tight crop of the part that matters ---------- +SQUARES = [ + ("seating", "/seating", "Seating", "Draw the room.
Then put people in it.", (330, 60, 880, 603), TOKENS["taupe"], "#eeeae3"), + ("place-cards", "/place-cards", "Place cards", "Straight from the room,
table numbers and all.", (330, 60, 870, 596), TOKENS["sage"], "#e4efec"), + ("timeline", "/timeline", "Timeline", "Pin the ceremony.
The day follows.", (320, 56, 880, 603), TOKENS["brass"], "#f1e8d7"), + ("money", "/money", "Money", "Booked, paid,
and what falls due.", (208, 70, 1024, 701), TOKENS["moss"], "#e6ede6"), + ("ceremony", "/ceremony", "Ceremony", "The order of service,
to the minute.", (0, 60, 1000, 685), TOKENS["slate"], "#e7ecf0"), + ("overview", "/", "Overview", "Where things stand,
and what's next.", (208, 70, 1024, 701), TOKENS["brass"], "#f1e8d7"), +] +for src, path, eyebrow, head, crop, accent, tint in SQUARES: + body = (f"
{eyebrow}
" + f"

{head}

" + + window(src, 80, 330, 920, path, crop) + + f"
💍 Trousseau
") + page(f"square-{src}", 1080, 1080, body, tint, accent) + +# ---------- The Binder, three phones ---------- +def phone(src, x, y, w): + h = w * 844 / 390 + return (f"
" + f"
" + f"
13:45•••
" + f"
") +body = (f"
The Binder
" + f"

The day, in your pocket.

" + f"

What's on now, who to ring, and where everyone sits — with or without signal.

" + + phone("binder-ring", 270, 300, 300) + phone("binder-now", 650, 262, 300) + phone("binder-find", 1030, 300, 300)) +page("binder-trio", 1600, 1000, body, "#f3e3dc", TOKENS["brass"]) + +# ---------- Every tool, one grid ---------- +TILES = [("overview", "Overview"), ("guests", "Guests"), ("seating", "Seating"), ("place-cards", "Place cards"), + ("timeline", "Timeline"), ("delegation", "Delegation"), ("group-shots", "Group shots"), ("ceremony", "Ceremony"), + ("boxes", "Boxes"), ("bar", "Bar"), ("money", "Money"), ("checklist", "Checklist")] +tiles = "".join( + f"
" + f"
{n}
" + for s, n in TILES) +body = (f"
Trousseau
" + f"

Every tool. One wedding.

" + f"

Add the ones you need. They all read the same guest list, the same room and the same day — and the Binder takes it to your phone.

" + f"
{tiles}
") +page("tools-grid", 1600, 1000, body) + +# ---------- Vertical story / Shorts cover, 1080x1920 ---------- +body = (f"
💍 Trousseau
" + f"

Your wedding,
in one place.

" + f"

Free. Private. Open source.
No sign-up to start.

" + + phone("binder-now", 276, 560, 500) + + f"
trousseau-suite.vercel.app
") +page("story", 1080, 1920, body, "#f3e3dc") + +# ---------- The pledge, 1080x1080 ---------- +items = [("Free, forever.", "No paid tier, no premium, no trial."), ("Your guests aren't the product.", "No adverts. No vendor leads. No data sales."), + ("Nobody browses your wedding.", "No admin panel, no support login."), ("It works with no account.", "Your guest list can stay on your laptop."), + ("You can always leave.", "The whole wedding, as one open file.")] +li = "".join(f"
  • {'i ii iii iv v'.split()[k]}." + f"
    {a}
    {b}
  • " for k, (a, b) in enumerate(items)) +body = (f"
    The Trousseau pledge
    " + f"

    Planning a wedding shouldn't cost you your privacy.

    " + f"
      {li}
    ") +page("pledge", 1080, 1080, body, "#f1e8d7") + +json.dump(manifest, open(f"{OUT}/manifest.json", "w"), indent=1) +print(len(manifest), "pages") diff --git a/marketing/assets/pipeline/edit.py b/marketing/assets/pipeline/edit.py new file mode 100644 index 0000000..9c0daae --- /dev/null +++ b/marketing/assets/pipeline/edit.py @@ -0,0 +1,67 @@ +"""Zoom-follow edit: crop every frame to an eased camera, add captions, write a new frame list.""" +import json, os, sys, shutil +from PIL import Image, ImageDraw, ImageFont, ImageFilter + +FONTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../../suite/public/fonts") +name, out_w, out_h = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) +src = f"work/frames/{name}"; dst = f"work/frames/{name}-edit" +shutil.rmtree(dst, ignore_errors=True); os.makedirs(dst) +meta = json.load(open(f"{src}/meta.json")) +vw, vh = meta["viewport"]["width"], meta["viewport"]["height"] +aspect = out_w / out_h +first = Image.open(f"{src}/{meta['frames'][0]['file']}"); scale = first.width / vw + +def fit(r): + """Grow a rect to the output's aspect, never smaller than a sane zoom, kept inside the viewport.""" + x, y, w, h = r["x"], r["y"], r["width"], r["height"] + cx, cy = x + w / 2, y + h / 2 + w = max(w, h * aspect, 560); h = w / aspect + if w > vw: w = vw; h = w / aspect + if h > vh: h = vh; w = h * aspect + x = min(max(cx - w / 2, 0), vw - w); y = min(max(cy - h / 2, 0), vh - h) + return (x, y, w, h) + +ease = lambda t: t * t * (3 - 2 * t) +times, t = [], 0.0 +for f in meta["frames"]: times.append(t); t += f["seconds"] +full = (0, 0, vw, vh) +cams = [{"frame": 0, "rect": full, "transition": 0}] + [{**c, "rect": fit(c["rect"])} for c in meta["cameras"]] + +def rect_at(i): + k = max((c for c in cams if c["frame"] <= i), key=lambda c: c["frame"]) + idx = cams.index(k); prev = cams[idx - 1]["rect"] if idx > 0 else k["rect"] + # Where the previous transition had got to when this one began. + if idx > 1: + p = cams[idx - 1]; pp = cams[idx - 2]["rect"] + pt = min(1, (times[k["frame"]] - times[p["frame"]]) / p["transition"]) if p["transition"] else 1 + prev = tuple(a + (b - a) * ease(pt) for a, b in zip(pp, p["rect"])) + u = min(1, (times[i] - times[k["frame"]]) / k["transition"]) if k["transition"] else 1 + return tuple(a + (b - a) * ease(u) for a, b in zip(prev, k["rect"])) + +def caption_at(i): + live = [c for c in meta["captions"] if c["frame"] <= i] + if not live or live[-1]["text"] is None: return None, 0 + c = live[-1]; return c["text"], times[i] - times[c["frame"]] + +font = ImageFont.truetype(f"{FONTS}/Lato-Regular.ttf", int(out_h * 0.034)) +lines = [] +for i, f in enumerate(meta["frames"]): + x, y, w, h = rect_at(i) + im = Image.open(f"{src}/{f['file']}").convert("RGB") + im = im.crop((round(x * scale), round(y * scale), round((x + w) * scale), round((y + h) * scale))).resize((out_w, out_h), Image.LANCZOS) + text, age = caption_at(i) + if text: + alpha = min(1, (age + f['seconds']) / 0.25) + layer = Image.new("RGBA", im.size, (0, 0, 0, 0)); d = ImageDraw.Draw(layer) + tw = d.textlength(text, font=font); ph = int(out_h * 0.075); pw = int(tw + ph * 1.1) + px, py = (out_w - pw) // 2, out_h - ph - int(out_h * 0.045) + shadow = Image.new("RGBA", im.size, (0, 0, 0, 0)); ImageDraw.Draw(shadow).rounded_rectangle((px, py + 6, px + pw, py + ph + 6), ph // 2, fill=(0, 0, 0, int(70 * alpha))) + layer = Image.alpha_composite(shadow.filter(ImageFilter.GaussianBlur(10)), layer); d = ImageDraw.Draw(layer) + d.rounded_rectangle((px, py, px + pw, py + ph), ph // 2, fill=(31, 27, 46, int(235 * alpha))) + d.text((out_w / 2, py + ph / 2), text, font=font, fill=(251, 248, 243, int(255 * alpha)), anchor="mm") + im = Image.alpha_composite(im.convert("RGBA"), layer).convert("RGB") + out = f"{i:05d}.png"; im.save(f"{dst}/{out}") + lines += [f"file '{out}'", f"duration {f['seconds']:.4f}"] +lines.append(f"file '{out}'") +open(f"{dst}/frames.txt", "w").write("\n".join(lines)) +print(f"{dst}: {len(meta['frames'])} frames at {out_w}x{out_h}") diff --git a/marketing/assets/pipeline/encode.py b/marketing/assets/pipeline/encode.py new file mode 100644 index 0000000..95f56cc --- /dev/null +++ b/marketing/assets/pipeline/encode.py @@ -0,0 +1,18 @@ +"""Frames (with per-frame durations) to MP4 (social, landing page) and GIF (README).""" +import os, subprocess, sys +import imageio_ffmpeg +FF = imageio_ffmpeg.get_ffmpeg_exe() +name, gif_w, mp4_w = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) +# `seat-to-card-edit` is published as `seat-to-card`, `binder-phone` as `binder`. +src = f"work/frames/{name}/frames.txt"; final = name.removesuffix("-edit").removesuffix("-phone") +os.makedirs("../motion", exist_ok=True) +def run(args): subprocess.run([FF, "-nostdin", "-hide_banner", "-loglevel", "error", "-y", *args], check=True, stdin=subprocess.DEVNULL) +# MP4: 30fps, sharp scaling, yuv420p so every player takes it. +run(["-f", "concat", "-safe", "0", "-i", src, "-vf", f"fps=30,scale={mp4_w}:-2:flags=lanczos", + "-c:v", "libx264", "-crf", "18", "-preset", "slow", "-pix_fmt", "yuv420p", "-movflags", "+faststart", f"../motion/{final}.mp4"]) +# GIF: a palette built from the whole clip, and only the part of each frame that changed re-quantised, so still text stays clean. +run(["-f", "concat", "-safe", "0", "-i", src, "-vf", + f"fps=12,scale={gif_w}:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=128:stats_mode=diff[p];[b][p]paletteuse=dither=none:diff_mode=rectangle", + "-loop", "0", f"../motion/{final}.gif"]) +for ext in ("mp4", "gif"): + print(f"../motion/{final}.{ext}: {os.path.getsize(f'../motion/{final}.{ext}')/1e6:.2f} MB") diff --git a/marketing/assets/pipeline/lib.mjs b/marketing/assets/pipeline/lib.mjs new file mode 100644 index 0000000..038c534 --- /dev/null +++ b/marketing/assets/pipeline/lib.mjs @@ -0,0 +1,37 @@ +import { fileURLToPath } from 'node:url'; +import { chromium } from '@playwright/test'; +export const BASE = process.env.BASE || 'http://localhost:3100'; +export const OUT = fileURLToPath(new URL('../screenshots', import.meta.url)); + +export async function launch() { return chromium.launch({ args: ['--lang=en-GB'] }); } + +/** A page in its own context, the example wedding stored where the app reads it. */ +export async function weddingPage(browser, { width = 1440, height = 900, scale = 2, video = null, time = null, init = null } = {}) { + const context = await browser.newContext({ + viewport: { width, height }, deviceScaleFactor: scale, locale: 'en-GB', timezoneId: 'Europe/London', colorScheme: 'light', + ...(video ? { recordVideo: { dir: video, size: { width, height } } } : {}), + }); + const page = await context.newPage(); + if (init) await page.addInitScript(init); + if (time) await page.clock.setFixedTime(new Date(time)); + await page.goto(BASE + '/'); + await page.evaluate(async () => { + const wedding = await (await fetch('/fixtures/example-wedding.trousseau.json')).json(); + await new Promise((resolve, reject) => { + const open = indexedDB.open('keyval-store'); + open.onupgradeneeded = () => open.result.createObjectStore('keyval'); + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const tx = open.result.transaction('keyval', 'readwrite'); + tx.objectStore('keyval').put(wedding, 'trousseau.document'); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }; + }); + }); + return { context, page }; +} + +/** Somewhere a few weeks before the example wedding, mid-morning at the venue. */ +export const PLANNING_TIME = '2028-04-18T09:30:00+01:00'; +export async function settle(page, ms = 1200) { await page.waitForLoadState('networkidle'); await page.waitForTimeout(ms); } diff --git a/marketing/assets/pipeline/montage.py b/marketing/assets/pipeline/montage.py new file mode 100644 index 0000000..30058a9 --- /dev/null +++ b/marketing/assets/pipeline/montage.py @@ -0,0 +1,20 @@ +"""Stills to a video: a slow push-in on each, crossfaded. H.264, 30fps.""" +import subprocess, sys, imageio_ffmpeg +FF = imageio_ffmpeg.get_ffmpeg_exe() +out, W, H, *names = sys.argv[1:]; W, H = int(W), int(H) +import os; CRF = os.environ.get("CRF", "26") +HOLD, FADE, FPS = 2.8, 0.5, 30 +frames = int(HOLD * FPS) +args, chains = [], [] +for i, n in enumerate(names): + args += ["-loop", "1", "-t", str(HOLD), "-i", f"../images/{n}.png"] + # Upscaled first so zoompan's integer steps are sub-pixel at the output size. + chains.append(f"[{i}:v]scale={W*3}:{H*3}:flags=lanczos,zoompan=z='1+0.045*on/{frames}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d={frames}:s={W}x{H}:fps={FPS},setsar=1,format=yuv420p[v{i}]") +last, t = "v0", HOLD - FADE +for i in range(1, len(names)): + chains.append(f"[{last}][v{i}]xfade=transition=fade:duration={FADE}:offset={t:.2f}[x{i}]") + last, t = f"x{i}", t + HOLD - FADE +subprocess.run([FF, "-nostdin", "-hide_banner", "-loglevel", "error", "-y", *args, "-filter_complex", ";".join(chains), "-map", f"[{last}]", + "-c:v", "libx264", "-crf", CRF, "-preset", "slow", "-pix_fmt", "yuv420p", "-movflags", "+faststart", f"work/{out}.mp4"], + check=True, stdin=subprocess.DEVNULL) +print(f"work/{out}.mp4") diff --git a/marketing/assets/pipeline/phone.py b/marketing/assets/pipeline/phone.py new file mode 100644 index 0000000..9947d7e --- /dev/null +++ b/marketing/assets/pipeline/phone.py @@ -0,0 +1,45 @@ +"""Put phone frames into a bezel on a brand card, caption above; 4:5 (1080x1350).""" +import json, os, sys, shutil +from PIL import Image, ImageDraw, ImageFont, ImageFilter +FONTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../../suite/public/fonts") +name = sys.argv[1]; W, H = 1080, 1350 +src = f"work/frames/{name}"; dst = f"work/frames/{name}-phone" +shutil.rmtree(dst, ignore_errors=True); os.makedirs(dst) +meta = json.load(open(f"{src}/meta.json")) +BG, INK, PAPER, GOLD = (243, 227, 220), (31, 27, 46), (251, 248, 243), (176, 138, 62) +title = ImageFont.truetype(f"{FONTS}/Marcellus-Regular.ttf", 58) +small = ImageFont.truetype(f"{FONTS}/Lato-Regular.ttf", 30) +status = ImageFont.truetype(f"{FONTS}/Lato-Regular.ttf", 24) +# Screen 390x844 CSS at 2.2 = 858x1857: too tall. Fit the screen to 1030 px high. +# The screen: a status bar, then the page at its own aspect under it. +sb = 54; ch = 1010; sw = round(ch * 390 / 844); sh = ch + sb; bez = 18 +px = (W - sw) // 2; py = H - sh - 110 +base = Image.new("RGB", (W, H), BG); d = ImageDraw.Draw(base) +# Soft shadow, then the body of the phone. +sh_layer = Image.new("RGBA", (W, H), (0, 0, 0, 0)) +ImageDraw.Draw(sh_layer).rounded_rectangle((px - bez, py - bez + 24, px + sw + bez, py + sh + bez + 24), 70, fill=(31, 27, 46, 90)) +base = Image.alpha_composite(base.convert("RGBA"), sh_layer.filter(ImageFilter.GaussianBlur(28))).convert("RGB"); d = ImageDraw.Draw(base) +d.rounded_rectangle((px - bez, py - bez, px + sw + bez, py + sh + bez), 64, fill=INK) +d.text((W / 2, H - 46), "Trousseau · the Binder, on the day", font=small, fill=(31, 27, 46), anchor="mm") +mask = Image.new("L", (sw, sh), 0); ImageDraw.Draw(mask).rounded_rectangle((0, 0, sw, sh), 48, fill=255) +times, t = [], 0.0 +for f in meta["frames"]: times.append(t); t += f["seconds"] +def caption_at(i): + live = [c for c in meta["captions"] if c["frame"] <= i] + return live[-1]["text"] if live and live[-1]["text"] else None +lines = [] +for i, f in enumerate(meta["frames"]): + im = base.copy(); d = ImageDraw.Draw(im) + screen = Image.new("RGB", (sw, sh), PAPER); sd = ImageDraw.Draw(screen) + sd.text((40, sb / 2 + 2), "13:45", font=status, fill=INK, anchor="lm") + sd.rounded_rectangle((sw / 2 - 56, 12, sw / 2 + 56, 42), 15, fill=INK) + for k in range(4): sd.rectangle((sw - 78 + k * 9, 36 - k * 5, sw - 72 + k * 9, 38), fill=INK) + screen.paste(Image.open(f"{src}/{f['file']}").convert("RGB").resize((sw, ch), Image.LANCZOS), (0, sb)) + im.paste(screen, (px, py), mask) + text = caption_at(i) + if text: d.text((W / 2, (py - bez) / 2), text, font=title, fill=INK, anchor="mm") + out = f"{i:05d}.png"; im.save(f"{dst}/{out}") + lines += [f"file '{out}'", f"duration {f['seconds']:.4f}"] +lines.append(f"file '{out}'") +open(f"{dst}/frames.txt", "w").write("\n".join(lines)) +print(f"{dst}: {len(meta['frames'])} frames") diff --git a/marketing/assets/pipeline/recorder.mjs b/marketing/assets/pipeline/recorder.mjs new file mode 100644 index 0000000..3a8aeb0 --- /dev/null +++ b/marketing/assets/pipeline/recorder.mjs @@ -0,0 +1,100 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; + +/** A pointer drawn into the page, because headless Chromium draws none. */ +export const CURSOR_SCRIPT = ` + addEventListener('DOMContentLoaded', () => { + const c = document.createElement('div'); + c.id = '__cursor'; + c.innerHTML = ''; + Object.assign(c.style, { position: 'fixed', left: '-100px', top: '-100px', zIndex: 2147483647, pointerEvents: 'none', filter: 'drop-shadow(0 2px 3px rgba(0,0,0,.25))', transform: 'translate(-5px,-3px)' }); + document.documentElement.appendChild(c); + const ring = document.createElement('div'); + Object.assign(ring.style, { position: 'fixed', width: '36px', height: '36px', marginLeft: '-18px', marginTop: '-18px', borderRadius: '50%', border: '3px solid rgba(47,111,94,.85)', background: 'rgba(47,111,94,.15)', zIndex: 2147483646, pointerEvents: 'none', opacity: 0, transform: 'scale(.4)', transition: 'transform .35s ease-out, opacity .45s ease-out' }); + document.documentElement.appendChild(ring); + addEventListener('mousemove', (e) => { c.style.left = e.clientX + 'px'; c.style.top = e.clientY + 'px'; }, true); + addEventListener('mousedown', (e) => { + ring.style.transition = 'none'; ring.style.left = e.clientX + 'px'; ring.style.top = e.clientY + 'px'; + ring.style.opacity = 1; ring.style.transform = 'scale(.4)'; + requestAnimationFrame(() => { ring.style.transition = 'transform .35s ease-out, opacity .45s ease-out'; ring.style.transform = 'scale(1.3)'; ring.style.opacity = 0; }); + }, true); + });`; + +const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); + +export class Recorder { + constructor(page, dir) { + this.page = page; this.dir = dir; this.frames = []; this.x = 720; this.y = 450; + this.cameras = []; this.captions = []; + rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true }); + } + async frame(seconds = 1 / 30) { + const file = `${this.dir}/${String(this.frames.length).padStart(5, '0')}.jpg`; + await this.page.screenshot({ path: file, type: 'jpeg', quality: 92 }); + this.frames.push({ file, seconds }); + } + /** Hold still: one frame shown for this long. */ + async hold(seconds) { await this.frame(seconds); } + /** Let something animate, sampling it at 12fps of wall time. */ + async live(seconds) { const n = Math.round(seconds * 12); for (let i = 0; i < n; i++) { await this.page.waitForTimeout(1000 / 12); await this.frame(1 / 12); } } + async moveTo(x, y, seconds = 0.7, { drag = false } = {}) { + const steps = Math.max(8, Math.round(seconds * 30)); const x0 = this.x, y0 = this.y; + for (let i = 1; i <= steps; i++) { + const t = ease(i / steps); + await this.page.mouse.move(x0 + (x - x0) * t, y0 + (y - y0) * t); + if (drag) await this.page.waitForTimeout(10); + await this.frame(1 / 30); + } + this.x = x; this.y = y; + } + async moveToLocator(locator, seconds, opts) { + await locator.scrollIntoViewIfNeeded(); + const box = await locator.boundingBox(); + await this.moveTo(box.x + box.width / 2, box.y + box.height / 2, seconds, opts); + } + async click(locator, seconds = 0.7) { + if (locator) await this.moveToLocator(locator, seconds); + await this.page.mouse.down(); await this.frame(1 / 30); await this.page.mouse.up(); + await this.live(0.35); + } + async type(text, perChar = 0.09) { for (const ch of text) { await this.page.keyboard.type(ch); await this.frame(perChar); } } + async press(key) { await this.page.keyboard.press(key); await this.live(0.4); } + /** + * Point the camera at these things (locators or {x,y,width,height} in CSS + * pixels), easing there over `transition` seconds from the next frame on. + * No targets means the whole viewport. + */ + async camera(targets = [], { pad = 48, transition = 0.7 } = {}) { + let rect; + if (targets.length === 0) rect = { x: 0, y: 0, width: this.page.viewportSize().width, height: this.page.viewportSize().height }; + else { + const boxes = await Promise.all(targets.map((t) => (t.boundingBox ? t.boundingBox() : t))); + const x0 = Math.min(...boxes.map((b) => b.x)), y0 = Math.min(...boxes.map((b) => b.y)); + const x1 = Math.max(...boxes.map((b) => b.x + b.width)), y1 = Math.max(...boxes.map((b) => b.y + b.height)); + rect = { x: x0 - pad, y: y0 - pad, width: x1 - x0 + 2 * pad, height: y1 - y0 + 2 * pad }; + } + this.cameras.push({ frame: this.frames.length, rect, transition }); + } + /** A caption from the next frame on; null clears it. */ + caption(text) { this.captions.push({ frame: this.frames.length, text }); } + /** The frame list ffmpeg's concat demuxer reads, each frame with its duration. */ + save() { + const lines = this.frames.flatMap((f) => [`file '${f.file.split('/').at(-1)}'`, `duration ${f.seconds.toFixed(4)}`]); + lines.push(`file '${this.frames.at(-1).file.split('/').at(-1)}'`); + writeFileSync(`${this.dir}/frames.txt`, lines.join('\n')); + const vp = this.page.viewportSize(); + writeFileSync(`${this.dir}/meta.json`, JSON.stringify({ viewport: vp, frames: this.frames.map((f) => ({ file: f.file.split('/').at(-1), seconds: f.seconds })), cameras: this.cameras, captions: this.captions })); + const total = this.frames.reduce((a, f) => a + f.seconds, 0); + console.log(`${this.dir}: ${this.frames.length} frames, ${total.toFixed(1)}s`); + } +} + +/** A fingertip for phone recordings: a soft dot that shows on each tap. */ +export const TOUCH_SCRIPT = ` + addEventListener('DOMContentLoaded', () => { + const dot = document.createElement('div'); + Object.assign(dot.style, { position: 'fixed', width: '44px', height: '44px', marginLeft: '-22px', marginTop: '-22px', borderRadius: '50%', background: 'rgba(31,27,46,.28)', border: '2px solid rgba(255,255,255,.9)', boxShadow: '0 2px 8px rgba(0,0,0,.25)', zIndex: 2147483647, pointerEvents: 'none', opacity: 0, transform: 'scale(.6)', transition: 'opacity .25s, transform .25s' }); + document.documentElement.appendChild(dot); + addEventListener('mousemove', (e) => { dot.style.left = e.clientX + 'px'; dot.style.top = e.clientY + 'px'; }, true); + addEventListener('mousedown', () => { dot.style.opacity = 1; dot.style.transform = 'scale(1)'; }, true); + addEventListener('mouseup', () => { setTimeout(() => { dot.style.opacity = 0; dot.style.transform = 'scale(.6)'; }, 180); }, true); + });`; diff --git a/marketing/assets/pipeline/render.mjs b/marketing/assets/pipeline/render.mjs new file mode 100644 index 0000000..d8df7c7 --- /dev/null +++ b/marketing/assets/pipeline/render.mjs @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs'; +import { chromium } from '@playwright/test'; +const manifest = JSON.parse(readFileSync('work/compose/manifest.json', 'utf8')); +const only = process.argv[2]; +const b = await chromium.launch(); +for (const m of manifest) { + if (only && !m.name.includes(only)) continue; + const p = await b.newPage({ viewport: { width: m.width, height: m.height }, deviceScaleFactor: m.scale }); + await p.goto(`file://${process.cwd()}/work/compose/${m.name}.html`); + await p.evaluate(() => document.fonts.ready); + await p.waitForLoadState('networkidle'); + await p.screenshot({ path: `../images/${m.name}.png` }); + await p.close(); + console.log('✓', m.name); +} +await b.close(); diff --git a/marketing/assets/pipeline/scene-binder.mjs b/marketing/assets/pipeline/scene-binder.mjs new file mode 100644 index 0000000..8e194c0 --- /dev/null +++ b/marketing/assets/pipeline/scene-binder.mjs @@ -0,0 +1,37 @@ +import { launch, weddingPage, BASE, settle } from './lib.mjs'; +import { Recorder, TOUCH_SCRIPT } from './recorder.mjs'; +const b = await launch(); +// 13:45 at the venue on the day: the ceremony is on now. +const { page } = await weddingPage(b, { width: 390, height: 844, scale: 3, time: '2028-06-01T13:45:00+01:00', init: TOUCH_SCRIPT }); +await page.goto(BASE + '/binder'); await settle(page, 1200); +const r = new Recorder(page, 'work/frames/binder'); +r.x = 200; r.y = 600; await page.mouse.move(200, 600); +const nav = page.getByRole('navigation', { name: 'The Binder' }); +const tap = async (loc, s = 0.5) => { await r.moveToLocator(loc, s); await page.mouse.down(); await r.frame(1 / 30); await page.mouse.up(); await r.live(0.45); }; + +r.caption('What’s on now, at the venue'); +await r.hold(2.4); +r.caption('The whole running order'); +await tap(nav.getByRole('button', { name: 'Day' })); +await r.hold(2.0); +r.caption('Who to ring, one tap away'); +await tap(nav.getByRole('button', { name: 'Ring' })); +await r.hold(1.8); +r.caption('Where does Zainab sit?'); +await tap(nav.getByRole('button', { name: 'Find' })); +await page.getByRole('searchbox').focus(); +await r.type('zainab l', 0.11); +await r.live(0.3); +await r.hold(1.8); +r.caption('The shot list, ticked off'); +await tap(nav.getByRole('button', { name: 'Shots' })); +const shots = page.getByRole('region', { name: 'The shot list' }).getByRole('checkbox'); +await tap(shots.nth(0), 0.45); +await tap(shots.nth(1), 0.45); +await tap(shots.nth(2), 0.45); +await r.hold(1.4); +r.caption('Works without signal'); +await tap(nav.getByRole('button', { name: 'Now' })); +await r.hold(2.2); +r.save(); +await b.close(); diff --git a/marketing/assets/pipeline/scene-ceremony-moves.mjs b/marketing/assets/pipeline/scene-ceremony-moves.mjs new file mode 100644 index 0000000..afb191f --- /dev/null +++ b/marketing/assets/pipeline/scene-ceremony-moves.mjs @@ -0,0 +1,45 @@ +import { launch, weddingPage, BASE, PLANNING_TIME, settle } from './lib.mjs'; +import { Recorder, CURSOR_SCRIPT } from './recorder.mjs'; +const b = await launch(); +const { page } = await weddingPage(b, { time: PLANNING_TIME, init: CURSOR_SCRIPT }); +await page.goto(BASE + '/timeline'); await settle(page, 1200); +await page.locator('header').getByRole('button', { name: '−', exact: true }).click(); await settle(page, 600); +const r = new Recorder(page, 'work/frames/ceremony-moves'); +await page.mouse.move(900, 300); r.x = 900; r.y = 300; +r.caption('Timeline: the whole day, in lanes'); +await r.hold(1.6); + +const ceremony = page.getByRole('button', { name: /^Ceremony, / }); +r.caption('The ceremony is pinned at 13:30'); +await r.click(ceremony, 0.9); +await r.live(0.4); + +const field = page.getByLabel('Anchored at'); +await field.scrollIntoViewIfNeeded(); +// The day on the canvas, from the ceremony down to dinner, and the verdict under it. +const mainDay = { x: 390, y: 480, width: 350, height: 330 }; +const verdict = page.getByText(/Nothing collides|overruns/).first(); + +async function moveCeremony(time, caption, after) { + await r.camera([field, page.getByText('Anchored', { exact: true })], { pad: 60, transition: 0.6 }); + r.caption(caption); + await r.moveToLocator(field, 0.7); + await field.click({ clickCount: 3 }); await r.frame(1 / 30); + await r.type(time, 0.12); + await r.live(0.2); + await r.camera([mainDay, verdict], { pad: 20, transition: 0.7 }); + r.caption(after); + await r.live(0.9); + await page.keyboard.press('Enter'); + await r.live(0.6); + await r.hold(2.4); +} +await moveCeremony('14:00', 'Move it to 14:00', 'Everything after it follows'); +console.log('drinks:', await page.getByRole('button', { name: /^Drinks reception, / }).getAttribute('aria-label')); +await moveCeremony('14:30', 'Later still?', 'It tells you what no longer fits'); +console.log('verdict:', await page.getByText(/Nothing collides|overruns/).first().textContent()); +await r.camera([], { transition: 0.8 }); +await r.live(1.0); +await r.hold(1.6); +r.save(); +await b.close(); diff --git a/marketing/assets/pipeline/scene-seat-to-card.mjs b/marketing/assets/pipeline/scene-seat-to-card.mjs new file mode 100644 index 0000000..383c91d --- /dev/null +++ b/marketing/assets/pipeline/scene-seat-to-card.mjs @@ -0,0 +1,55 @@ +import { launch, weddingPage, BASE, PLANNING_TIME, settle } from './lib.mjs'; +import { Recorder, CURSOR_SCRIPT } from './recorder.mjs'; +const b = await launch(); +const { page } = await weddingPage(b, { time: PLANNING_TIME, init: CURSOR_SCRIPT }); +await page.goto(BASE + '/seating'); await settle(page, 1500); +const r = new Recorder(page, 'work/frames/seat-to-card'); +await page.mouse.move(r.x, r.y); +r.caption('Seating: the room, drawn to scale'); +await r.hold(1.4); + +const search = page.getByPlaceholder(/Search guests/); +const table13 = page.getByRole('button', { name: /^Table 13, / }); +const t13 = await table13.boundingBox(); +await r.camera([search, { ...t13, height: t13.height + 150 }, page.getByRole('button', { name: /^Table 11, / })], { pad: 40 }); +r.caption('Find a guest'); +await r.click(search, 0.8); +await r.type('Zainab T'); +await r.live(0.5); + +r.caption('Drag her to a table'); +const guest = page.getByRole('button', { name: /^Zainab Thistlewood/ }); +await r.moveToLocator(guest, 0.6); +await page.mouse.down(); await r.frame(1 / 30); +const t = await table13.boundingBox(); +await r.moveTo(t.x + t.width / 2, t.y + t.height / 2, 1.3, { drag: true }); +await page.mouse.up(); +await r.live(1.0); + +await r.camera([], { transition: 0.6 }); +r.caption('Open Place cards'); +await r.click(page.getByRole('link', { name: 'Place cards' }), 0.9); +await settle(page, 600); await r.live(0.5); + +const useRoom = page.getByRole('button', { name: /^Use the room/ }); +await r.camera([useRoom, page.getByText(/^Card \d+ of \d+$/)], { pad: 30 }); +r.caption('Take the guest list from the room'); +await r.click(useRoom, 0.9); +await r.live(0.7); + +await r.camera([], { transition: 0.5 }); +r.caption(null); +await r.click(page.getByText(/^Rows — /), 0.8); +await r.live(0.3); +const row = page.getByRole('button', { name: 'Zainab Thistlewood', exact: true }); +await r.click(row, 0.9); +await r.live(0.3); + +const cardArea = page.getByText(/^Card \d+ of \d+$/); +const box = await cardArea.boundingBox(); +await r.camera([{ x: box.x - 170, y: 115, width: box.width + 340, height: box.y - 115 + box.height }], { pad: 16, transition: 0.9 }); +r.caption('Her card has her table'); +await r.moveTo(box.x + box.width + 200, box.y + 20, 0.9); +await r.hold(3.0); +r.save(); +await b.close(); diff --git a/marketing/assets/pipeline/stills.mjs b/marketing/assets/pipeline/stills.mjs new file mode 100644 index 0000000..4ab91ca --- /dev/null +++ b/marketing/assets/pipeline/stills.mjs @@ -0,0 +1,37 @@ +import { launch, weddingPage, BASE, OUT, PLANNING_TIME, settle } from './lib.mjs'; +const b = await launch(); +const { page } = await weddingPage(b, { time: PLANNING_TIME }); +const shot = async (name) => { await page.screenshot({ path: `${OUT}/${name}.png` }); console.log('✓', name); }; +const go = async (r) => { await page.goto(BASE + r); await settle(page); }; + +await go('/'); await shot('overview'); +await page.mouse.wheel(0, 500); await page.waitForTimeout(500); await shot('overview-scrolled'); +await go('/guests'); await shot('guests'); +await go('/seating'); await shot('seating'); +await go('/place-cards'); await shot('place-cards'); +await go('/timeline'); await shot('timeline'); +await page.getByRole('button', { name: /^Ceremony, / }).click(); await page.waitForTimeout(600); await shot('timeline-ceremony-selected'); +await go('/delegation'); await shot('delegation'); +await go('/group-shots'); await page.getByText(/The couple with Alex.s parents/).first().click(); await page.waitForTimeout(600); await shot('group-shots'); +await go('/ceremony'); await shot('ceremony'); +await go('/boxes'); await page.getByText(/The rings and the paperwork/).first().click(); await page.waitForTimeout(600); await shot('boxes'); +await go('/bar'); await shot('bar'); +await go('/money'); await shot('money'); +await go('/checklist'); await shot('checklist'); +await go('/'); await page.getByRole('button', { name: 'Add or remove tools' }).click(); await page.waitForTimeout(700); await shot('toolbox'); +await go('/seating'); await page.keyboard.press('Control+k'); await page.waitForTimeout(400); +await page.getByLabel('Find a guest, table, block, job or page').pressSequentially('zain', { delay: 60 }); await page.waitForTimeout(700); await shot('command-palette'); +await b.close(); + +// The Binder, on a phone, at 13:45 on the day: the ceremony is "now". +const p2 = await launch(); +const phone = await weddingPage(p2, { width: 390, height: 844, scale: 3, time: '2028-06-01T13:45:00+01:00' }); +const ph = phone.page; +const pshot = async (name) => { await ph.screenshot({ path: `${OUT}/${name}.png` }); console.log('✓', name); }; +await ph.goto(BASE + '/binder'); await settle(ph); await pshot('binder-now'); +const nav = ph.getByRole('navigation', { name: 'The Binder' }); +await nav.getByRole('button', { name: 'Day' }).click(); await ph.waitForTimeout(500); await pshot('binder-day'); +await nav.getByRole('button', { name: 'Ring' }).click(); await ph.waitForTimeout(500); await pshot('binder-ring'); +await nav.getByRole('button', { name: 'Find' }).click(); await ph.getByRole('searchbox').fill('zainab'); await ph.waitForTimeout(500); await pshot('binder-find'); +await nav.getByRole('button', { name: 'Shots' }).click(); await ph.getByRole('region', { name: 'The shot list' }).getByRole('checkbox').first().check(); await ph.getByRole('region', { name: 'The shot list' }).getByRole('checkbox').nth(1).check(); await ph.waitForTimeout(500); await pshot('binder-shots'); +await p2.close(); diff --git a/marketing/assets/screenshots/bar.png b/marketing/assets/screenshots/bar.png new file mode 100644 index 0000000..1bbc3e5 Binary files /dev/null and b/marketing/assets/screenshots/bar.png differ diff --git a/marketing/assets/screenshots/binder-day.png b/marketing/assets/screenshots/binder-day.png new file mode 100644 index 0000000..aa6e69d Binary files /dev/null and b/marketing/assets/screenshots/binder-day.png differ diff --git a/marketing/assets/screenshots/binder-find.png b/marketing/assets/screenshots/binder-find.png new file mode 100644 index 0000000..72fe77f Binary files /dev/null and b/marketing/assets/screenshots/binder-find.png differ diff --git a/marketing/assets/screenshots/binder-now.png b/marketing/assets/screenshots/binder-now.png new file mode 100644 index 0000000..3fb7b2f Binary files /dev/null and b/marketing/assets/screenshots/binder-now.png differ diff --git a/marketing/assets/screenshots/binder-ring.png b/marketing/assets/screenshots/binder-ring.png new file mode 100644 index 0000000..e3f9d84 Binary files /dev/null and b/marketing/assets/screenshots/binder-ring.png differ diff --git a/marketing/assets/screenshots/binder-shots.png b/marketing/assets/screenshots/binder-shots.png new file mode 100644 index 0000000..5633666 Binary files /dev/null and b/marketing/assets/screenshots/binder-shots.png differ diff --git a/marketing/assets/screenshots/boxes.png b/marketing/assets/screenshots/boxes.png new file mode 100644 index 0000000..f508cf9 Binary files /dev/null and b/marketing/assets/screenshots/boxes.png differ diff --git a/marketing/assets/screenshots/ceremony.png b/marketing/assets/screenshots/ceremony.png new file mode 100644 index 0000000..d1b9135 Binary files /dev/null and b/marketing/assets/screenshots/ceremony.png differ diff --git a/marketing/assets/screenshots/checklist.png b/marketing/assets/screenshots/checklist.png new file mode 100644 index 0000000..e4d5f59 Binary files /dev/null and b/marketing/assets/screenshots/checklist.png differ diff --git a/marketing/assets/screenshots/command-palette.png b/marketing/assets/screenshots/command-palette.png new file mode 100644 index 0000000..d48b671 Binary files /dev/null and b/marketing/assets/screenshots/command-palette.png differ diff --git a/marketing/assets/screenshots/delegation.png b/marketing/assets/screenshots/delegation.png new file mode 100644 index 0000000..82a788e Binary files /dev/null and b/marketing/assets/screenshots/delegation.png differ diff --git a/marketing/assets/screenshots/group-shots.png b/marketing/assets/screenshots/group-shots.png new file mode 100644 index 0000000..fecc4c3 Binary files /dev/null and b/marketing/assets/screenshots/group-shots.png differ diff --git a/marketing/assets/screenshots/guests.png b/marketing/assets/screenshots/guests.png new file mode 100644 index 0000000..e384b87 Binary files /dev/null and b/marketing/assets/screenshots/guests.png differ diff --git a/marketing/assets/screenshots/money.png b/marketing/assets/screenshots/money.png new file mode 100644 index 0000000..ef26c11 Binary files /dev/null and b/marketing/assets/screenshots/money.png differ diff --git a/marketing/assets/screenshots/overview-scrolled.png b/marketing/assets/screenshots/overview-scrolled.png new file mode 100644 index 0000000..b98e0f5 Binary files /dev/null and b/marketing/assets/screenshots/overview-scrolled.png differ diff --git a/marketing/assets/screenshots/overview.png b/marketing/assets/screenshots/overview.png new file mode 100644 index 0000000..ee2b47f Binary files /dev/null and b/marketing/assets/screenshots/overview.png differ diff --git a/marketing/assets/screenshots/place-cards.png b/marketing/assets/screenshots/place-cards.png new file mode 100644 index 0000000..b8ce612 Binary files /dev/null and b/marketing/assets/screenshots/place-cards.png differ diff --git a/marketing/assets/screenshots/seating.png b/marketing/assets/screenshots/seating.png new file mode 100644 index 0000000..ca764cd Binary files /dev/null and b/marketing/assets/screenshots/seating.png differ diff --git a/marketing/assets/screenshots/timeline-ceremony-selected.png b/marketing/assets/screenshots/timeline-ceremony-selected.png new file mode 100644 index 0000000..358cd71 Binary files /dev/null and b/marketing/assets/screenshots/timeline-ceremony-selected.png differ diff --git a/marketing/assets/screenshots/timeline.png b/marketing/assets/screenshots/timeline.png new file mode 100644 index 0000000..ed32f58 Binary files /dev/null and b/marketing/assets/screenshots/timeline.png differ diff --git a/marketing/assets/screenshots/toolbox.png b/marketing/assets/screenshots/toolbox.png new file mode 100644 index 0000000..3a44db0 Binary files /dev/null and b/marketing/assets/screenshots/toolbox.png differ diff --git a/marketing/copy/hacker-news.md b/marketing/copy/hacker-news.md new file mode 100644 index 0000000..4d8ab5b --- /dev/null +++ b/marketing/copy/hacker-news.md @@ -0,0 +1,168 @@ +# Hacker News: Show HN + +**Post from:** the maintainer's own account. +**When:** Tuesday to Thursday, 8–10am US Eastern. +**Link to:** `https://github.com/JFrusher/Trousseau`. The hosted app needs no +sign-up, so it satisfies Show HN's "something people can try" rule. Put the +app link in the first line of the text. +**Stay:** in the thread for at least three hours, and answer every technical +question. + +Show HN guidelines: the title starts with "Show HN:". No superlatives, no +marketing language, and no asking for upvotes. The ask below is for feedback +and code review, which HN welcomes. Leave stars to the README. + +--- + +## Title variants + +Pick one. Each is under 80 characters. + +1. `Show HN: Trousseau – open-source wedding planner where the tools share one document` +2. `Show HN: I built an open-source, self-hostable wedding planner` +3. `Show HN: A local-first wedding planner – seating, place cards, run sheet, one file` +4. `Show HN: Trousseau – free wedding planning tools that share one guest list` + +**Recommended: 1.** It names the idea that is actually new, and "one document" +is what an HN reader will want to argue about. + +--- + +## Body + +> Try it (no sign-up, nothing leaves your browser): https://trousseau-suite.vercel.app +> Code: https://github.com/JFrusher/Trousseau +> +> I built Trousseau for my own wedding, after two of the apps we were using +> disagreed about what day it was. +> +> Every wedding planning tool I tried was a set of separate pages sharing a +> login. The seating chart, the place cards and the run sheet each held their +> own copy of the guest list, and the copies drifted. The "free" apps are paid +> for by vendor marketplaces, registry commissions and upsells. Their product +> is a spreadsheet of your guests' names, emails, family relationships and, +> via dietary requirements, sometimes medical details. +> +> So Trousseau is one JSON document per wedding, with eleven tools around it: +> seating (a room drawn to scale), place cards, a timeline, job delegation, +> group photos, ceremony, packing boxes, a bar calculator, money, a checklist +> and a phone "binder" for the day. Each tool owns one slice of the document +> and reads everyone else's. Seat your guests and one click puts every table +> number on the place cards. Pin the ceremony, let the rest of the day follow it, and moving +> the ceremony ten minutes moves every job hanging off it. +> +> Some technical bits people here might find interesting: +> +> **The merge rule.** A tool rewrites only its own slice and copies every +> other key byte for byte, including keys from tools that don't exist yet. +> The merge runs on raw stored data, not the parsed document. A bug in a zod +> schema should at worst refuse a read, never destroy a write. That is also +> how a new tool ships without a new version of the others. +> +> **The timeline resolver.** It is one pure function that the screen and +> every PDF read. Blocks are either pinned to a clock time or follow their +> predecessor plus a gap. A lane is walked in stretches between pinned +> blocks. If a chain overruns the next pinned time, the overrun comes out of +> blocks marked squeezable (each has a minimum). Anything left over is +> reported as a collision rather than silently moved. The resolved times go +> into a separate `day` slice, so Delegation never runs a scheduler of its +> own. Sunset and golden hour use the NOAA solar equations offline. There is +> no timezone database: the UTC offset is typed in, because a planner knows +> whether it is BST and tzdata costs 300 kB. +> +> **Checks between tools.** Each tool validates its own slice, but the bugs +> worth catching live between them: two slices claiming different dates, a +> table over capacity, a seat holding two people, and confirmed guests with +> no table. The same validator is a hard gate on the server's write path. +> +> **Storage.** It is local-first: IndexedDB, no account needed. Accounts use +> Supabase: Postgres JSONB with one document per wedding, row-level security, +> compare-and-set writes, real-time sync, and bounded version history. +> Conflicts are shown to the user, never silently resolved. +> +> **Privacy.** Encrypted at rest, not end to end, and the README and privacy +> policy say so plainly. There is no admin panel. A guest's seat link serves +> only ciphertext, with the key after the `#`. Self-hosted off Vercel, it +> sends no analytics at all. +> +> **Stack:** Next.js 16, React 19, TypeScript, Zustand, zod 4, Tailwind 4, +> Supabase, pdf-lib and jsPDF for print. Vitest has 1,800+ cases, including +> RLS policies tested against PGlite. Playwright with axe runs against the +> production build. +> +> **On Claude Code:** about half the commits are co-authored with it, and +> I'd rather be upfront about how. Every subsystem got a dated spec and then +> a plan in `docs/superpowers/`, which I approved before anything was built. +> The plans record where building them found the plan was wrong. That +> record turned out to be the most valuable part. Two examples: +> +> - A test that was meant to keep the privacy policy honest checked the +> policy's own wording, not the layout. So when analytics were added to the +> layout, nothing noticed. The test now reads the layout. +> - Renaming a field on a loose zod object (`looseObject`) left `tsc` +> completely silent, because the inferred type has an index signature. +> That one needed a guard asserting against the schema's shape. +> +> The pattern I'd pass on: make the agent write down what it expects, then +> what it found. The diff between the two is where the bugs are. +> +> Licensing: the app is AGPL-3.0, so nobody can run a closed paid fork of +> the hosted service. The data contract is an MIT npm package +> (`@jfrusher/trousseau`), so anyone can build a tool that reads the file. +> There is no paid tier and there won't be one. +> +> What I'd really like from HN: +> +> - Criticism of the one-document, slice-per-tool design. Where does it break? +> - A review of the RLS and `security definer` setup in `supabase/migrations/`. +> - Self-hosters: is "Next.js + optional Supabase, no Docker" a dealbreaker, +> or fine? +> - If you're planning a wedding, or helped someone plan one, what job did +> no tool do for you? +> +> Happy to answer anything. + +--- + +## Prepared answers for likely comments + +**"Why not Docker?"** +> Deliberate so far. Local-only is `npm ci && npm run build && npm run dev -w +> suite`, and there is no backend at all. Sync needs Supabase, which has its +> own self-hosting story. A Dockerfile would be a second thing to keep working. +> If people here would actually use one, I'm open to it. Tell me what it would +> make easier. + +**"Why not CRDTs / Automerge / Yjs?"** +> Two or three editors per wedding, who mostly edit different slices. So +> compare-and-set per document, plus a merge that works slice by slice in the +> browser, covered it. A true conflict is shown to the user, because +> "someone else changed this table" is a question for a human. If the planner +> side grows to agency teams, I'd revisit it. + +**"Why not normalised tables?"** +> I considered a row per slice and rejected it: one compare-and-set would +> become up to sixteen. The cross-slice check that refuses a wedding with two +> dates would need a transaction around them. And the live channel would +> announce several versions per change. The database review in +> `docs/superpowers/specs/` has the reasoning. + +**"How do you make money?"** +> I don't, and that's the point. Hosting a wedding's JSON is cheap. If usage +> grows there's a Ko-fi; there will never be a paid tier. + +**"Is it really private if you host it?"** +> Encrypted at rest with RLS, no admin panel, and no support login. But I +> administer the database, and the privacy policy says so plainly. If that's +> not enough, and for some people it shouldn't be, use it with no account at +> all, or self-host it. + +**"Does it do RSVPs?"** +> No, on purpose. Joy and others do that well. Trousseau imports your RSVPs +> from their CSV exports and flags confirmed guests with no table. + +**"Did the AI write it all?"** +> About half the commits are co-authored. The design decisions, the specs, +> and which trade-offs to take are mine and written down. The approved spec +> and plan for every subsystem are in the repo, so you can judge for +> yourself. diff --git a/marketing/copy/reddit-posts.md b/marketing/copy/reddit-posts.md new file mode 100644 index 0000000..3e082a8 --- /dev/null +++ b/marketing/copy/reddit-posts.md @@ -0,0 +1,237 @@ +# Reddit posts + +Three posts, one per community, each written for how that community talks. +Space them at least a day apart. + +**Before posting any of them:** + +- Read the subreddit's sidebar and pinned rules **on the day**. Self-promotion + rules change, and some subs only allow projects on certain days or with a + specific flair. +- Post from the maintainer's own account and reply to comments for the first + few hours. +- Screenshots use the guided tour's example wedding. Never a real guest list. +- The personal details (your own wedding, what you found hard) are yours to + tell. Edit anything here that isn't exactly how it happened. On Reddit, one + detail that turns out to be embellished undoes the whole post. + +--- + +## 1. r/selfhosted + +**Title:** +`Trousseau: a local-first, self-hostable wedding planner (seating, place cards, run sheet), AGPL, no analytics when self-hosted` + +**Flair:** use whatever the sub currently requires for a project you made. + +**Body:** + +> My partner and I planned our wedding with a handful of apps. Two of them +> ended up disagreeing about the date, and all of them wanted our guest list: +> names, emails, family relationships, and dietary requirements, some of +> which are really medical information. So I built my own, and I've kept +> building it for other couples. +> +> **What it is:** a planning suite where every tool shares one JSON document. +> Seat people in the floor plan and one click puts every table number on the +> place cards. Move the ceremony and every job and block after it moves too. There +> are eleven tools: seating to scale, place cards, timeline, delegation, +> group photos, ceremony, boxes, bar, money, checklist, and a phone binder +> that works offline. +> +> **The self-hosting bits you'll care about:** +> +> - **Local-first.** With no account, the whole wedding lives in the +> browser's IndexedDB and nothing leaves the device. This works the same on +> the hosted site and on your own copy. +> - **No analytics on your instance.** The page counter only renders when +> running on Vercel, and Sentry only initialises if you set a DSN. Leave +> both alone and it phones home to nothing. +> - **Your domain, your database.** Accounts, sync between devices, and +> partner and planner access need a Supabase project, either their cloud +> free tier or self-hosted Supabase. Apply the migrations in order and the +> row-level security does the tenant isolation. +> - **Export everything** as one `.trousseau.json` file. The schema is a +> published MIT npm package, so you can script against it. +> - **AGPL-3.0**, so nobody can take the hosted version closed. +> +> **Setup, local-only:** +> +> ``` +> git clone https://github.com/JFrusher/Trousseau.git +> cd Trousseau +> npm ci +> npm run build +> npm run dev -w suite +> ``` +> +> **About Docker, before you ask:** there is no image yet. That was a +> deliberate choice: it's one Next.js process plus an optional Supabase +> project, and I didn't want a second thing to maintain. But this is exactly +> the crowd to tell me if that's wrong. Would you actually run it from a +> compose file? What would you want in it: just the app, or the app plus +> Supabase? +> +> The self-hosting guide was written by running every command on a fresh +> clone. It lists every env var, the migration order, and the two mistakes +> that catch everyone out. It also ends with a section on checking that the +> instance actually works, not just that it starts: +> https://github.com/JFrusher/Trousseau/blob/main/docs/SELF-HOSTING.md +> +> Repo: https://github.com/JFrusher/Trousseau +> Hosted, if you just want to click around (no sign-up): https://trousseau-suite.vercel.app +> +> Happy to answer anything about the architecture or the RLS setup. + +**Replies to have ready:** + +- *"Why Supabase and not plain Postgres?"* Auth by magic link, row-level + security and the realtime channel all come from it. The data is plain + Postgres JSONB, and the migrations are plain SQL. +- *"Can I run it without Supabase at all?"* Yes. Everything but accounts, + sync and share links works with no backend. Set nothing, and the account + routes answer 501 and say accounts aren't set up on this deployment. +- *"Is the data E2E encrypted?"* No. It's encrypted at rest with RLS, and the + privacy policy says so. On your own instance, you are the operator. + +--- + +## 2. r/WeddingsUnder10k + +**Title:** +`I made a free wedding planner with no ads or upsells: seating chart, place cards that fill in table numbers, and a day-of timeline` + +**Body:** + +> Hi all! We got married recently. The planning apps we +> tried were "free" in the way that means you're shown vendor ads, nudged +> towards paid stationery, and asked for everyone's email addresses. So I +> built my own set of tools, and now I've made it free for anyone. +> +> It's called **Trousseau**. It's genuinely free: no premium version, no +> trial, no ads, and nothing to upgrade to later. It's open source, so that +> can't quietly change. +> +> **What it does:** +> +> - 🪑 **Seating chart drawn to scale.** Draw your actual room, drag tables +> in, drag guests onto seats. It keeps a running count of dietary needs as +> you go, and you can say "keep these two apart" and it'll warn you. +> - 💌 **Place cards you print at home.** Once people are seated, the cards +> fill in their own table numbers. Use your own design, print on your own +> card stock, and skip paying per card. It even refuses to print a card with +> a missing name or font, so you don't waste good card. +> - 🕒 **Day-of timeline.** Pin the ceremony time and let everything else +> follow. If the ceremony moves, the whole day moves with it. It warns you +> when things overlap or run past the venue's curfew, and it knows when +> golden hour is for photos. +> - 📋 **Who's doing what.** Hand out jobs to your helpers (setting up +> chairs, bringing the cake stand) and print a sheet for each of them. +> - 💷 **Money.** What each supplier costs, what's paid, and what's due when, +> against your budget. +> - 🍷 **Bar calculator.** How many bottles and cases to buy for your guest +> count, if you're doing your own drinks. (It's UK-built, so it thinks in +> UK units, but the bottle counts work anywhere.) +> - 📱 **Day-of binder on your phone** that works without signal, because +> venues never have signal. +> +> **The privacy bit:** you don't need an account. It saves in your browser, +> and your guest list never leaves your computer. If you want to plan with +> your partner on two devices, you can make an account, but there's no +> password, and no one (including me) browses weddings. +> +> **Already have your guest list somewhere?** Export it from Joy, Zola or The +> Knot, or from your spreadsheet, and import the CSV. It works out which +> column is which and shows you a preview first. +> +> There's a guided tour with a pretend wedding if you just want to poke +> around: https://trousseau-suite.vercel.app +> +> It doesn't do RSVPs or a wedding website. Joy is great for that and free, +> and Trousseau imports the RSVPs from it. +> +> I'd love to know what's missing, what's confusing, or what job you're still +> doing in a spreadsheet. That's how every tool in it so far got built. + +**Replies to have ready:** + +- *"Is it really free? What's the catch?"* No catch. It costs me very little + to host, there's a Ko-fi if people want to chip in, and the licence means + it can't become a paid product later. +- *"Does it work on a phone?"* The Binder is made for phones on the day. + Planning (drawing the room, designing cards) is a laptop job. +- *"Can my planner use it?"* Yes. A wedding can have two partners and one + planner, and planners can hold many weddings. + +--- + +## 3. r/opensource (or r/webdev) + +**Title (r/opensource):** +`Trousseau: an AGPL wedding planner built around one shared document. Looking for contributors, and there's a guide to adding a whole tool` + +**Title (r/webdev):** +`I built a wedding planner as 11 tools sharing one JSON document: architecture notes and lessons (Next.js 16, Supabase, zod)` + +**Body (works for both; trim the last section for r/webdev if its rules +discourage recruiting):** + +> Trousseau started as four separate apps I wrote for my own wedding: seating, +> place cards, a run-of-day timeline, and a jobs list. They each kept their own +> copy of the guest list, and one day two of them disagreed about the wedding +> date. The fix became the architecture. +> +> **One document, one owner per slice.** The wedding is one JSON document. +> Each tool rewrites only its own slice and copies every other key +> byte-for-byte, including keys belonging to tools that don't exist yet. The +> merge runs on raw stored data, not the parsed result, so a schema bug can +> refuse a read but never corrupt a write. Tools communicate only by reading +> each other's slices: the timeline publishes resolved clock times into a +> `day` slice, and delegation reads those instead of running its own +> scheduler. +> +> **Cross-slice validation.** Each tool validates itself. A separate checker +> looks only at what no single tool can see, like two slices claiming +> different dates or a guest seated at a table that doesn't list them. The +> same checker gates writes on the server. +> +> **What's in the repo:** +> +> - Next.js 16 (App Router), React 19, TypeScript, Zustand, zod 4, +> Tailwind 4 +> - Supabase: Postgres JSONB, RLS, magic-link auth, realtime, bounded history +> - An MIT npm package (`@jfrusher/trousseau`) holding the schemas and file +> format, separate from the AGPL app, so third-party tools can read the +> file +> - 1,800+ Vitest cases, RLS tested against PGlite, and Playwright with axe +> against the production build +> - Dated design specs and implementation plans for every subsystem in +> `docs/superpowers/`, written before code, including where the plan turned +> out to be wrong +> +> **Lessons that might save you time:** +> +> 1. A zod `looseObject` gives an inferred type with an index signature, so +> renaming a field compiles silently. Assert against `schema.shape` at the +> boundary. +> 2. If a test is meant to keep your privacy policy honest, make it read the +> code that collects data, not the policy's own wording. Ours checked the +> wording, and analytics were added without it noticing. +> 3. Supabase's Security Advisor warns about `security definer` functions. +> On a function that RLS policies call, its suggested fixes can lock every +> user out of their own data, or recurse until the stack overflows. We met +> both. +> 4. `npm pack` includes a root `LICENSE` even when `files` omits it. An +> AGPL `LICENSE` would have shipped inside an MIT package. +> +> **Contributing.** There's a list of well-scoped "tools feeding each other" +> proposals in the roadmap. For example, packing boxes showing up on +> whoever's carrying them, or the bar's estimated spend appearing in the +> budget. There's also a guide to building an entirely new tool, from "is +> this a tool?" to merged PR. +> +> - Repo: https://github.com/JFrusher/Trousseau +> - Roadmap: https://github.com/JFrusher/Trousseau/blob/main/ROADMAP.md +> - Try it (no sign-up): https://trousseau-suite.vercel.app +> +> Critique of the design is as welcome as PRs. diff --git a/marketing/copy/social-media.md b/marketing/copy/social-media.md new file mode 100644 index 0000000..8e8ceff --- /dev/null +++ b/marketing/copy/social-media.md @@ -0,0 +1,175 @@ +# Social media + +## 1. Build thread: Twitter/X and LinkedIn + +Seven parts. On X, post it as a thread. On LinkedIn, join the parts into one +post with a blank line between them, drop the numbering, and put the links in +the first comment (LinkedIn shows posts with outbound links to fewer people). + +Attach media to posts 1, 3 and 5. Everything is in `../assets/` (see its +README). Use the example wedding, never real names. + +--- + +**1/7** +> Two of the apps we used to plan our wedding disagreed about what day it +> was. +> +> So I built one where that can't happen, and made it free and open source +> for everyone. +> +> Here's how Trousseau works, and what building it with Claude Code taught +> me 🧵 +> +> [Attach: `assets/motion/seat-to-card.mp4`] + +**2/7** +> The problem with wedding apps isn't missing features. It's that every +> feature keeps its own copy of your guest list. +> +> Seating chart, place cards, run sheet: three copies of one list, drifting +> apart until the week of the wedding. + +**3/7** +> Trousseau is one JSON document per wedding, with 11 tools around it. +> +> The rule: a tool rewrites only its own slice and copies everything else +> byte for byte, even keys from tools that don't exist yet. +> +> Seat people, and one click puts every table number on the cards. Move +> the ceremony and every job after it moves. +> +> [Attach: `assets/motion/ceremony-moves.mp4`] + +**4/7** +> Built with Claude Code: about half the commits are co-authored. +> +> What worked was a paper trail. Every subsystem got a dated spec, then a +> plan I approved, then the build. And the plan records where reality +> disagreed with it. +> +> The disagreements are where the bugs were. + +**5/7** +> Lessons from those plans: +> +> • A zod looseObject made a renamed field compile silently +> • Our privacy test checked its own wording, so analytics slipped in +> unnoticed. It now reads the code +> • A security advisor's "fix" would have locked every couple out of their +> own wedding +> +> [Screenshot: a plan's "what building it found" section] + +**6/7** +> What it is today: +> +> 🪑 Seating to scale · 💌 Place cards · 🕒 Timeline with collisions and +> golden hour · 📋 Jobs · 📷 Group shots · 💍 Ceremony · 📦 Boxes · 🍷 Bar · +> 💷 Money · ✅ Checklist · 📱 Offline phone binder +> +> No account needed. No ads. No paid tier, ever. AGPL, self-hostable. + +**7/7** +> If you're planning a wedding: it's free, and your guest list never has to +> leave your laptop → trousseau-suite.vercel.app +> +> If you build things: the code, specs and a guide to adding your own tool +> are on GitHub. A ⭐ helps other couples find it → +> github.com/JFrusher/Trousseau + +--- + +**Hashtags (X, use at most two):** #opensource #buildinpublic +**Hashtags (LinkedIn, at the end):** #OpenSource #TypeScript #NextJS +#Supabase #ClaudeCode #WeddingPlanning + +--- + +## 2. Short-form video scripts (TikTok, Instagram Reels, YouTube Shorts) + +Recording notes for all three: + +- Record the screen at 1080×1920. Either crop a desktop recording into + vertical panels, or show the laptop on camera and cut to screen. +- Use the guided tour's example wedding. **Never film a real guest list.** +- Captions burned in, because most people watch muted. Keep each caption + line under six words. +- End card: "Free · no sign-up · trousseau-suite.vercel.app". +- Don't film on a phone: the planning tools are made for a laptop. Film the + Binder on the phone in script 3 only. + +--- + +### Script 1: "Seating chart chaos, solved in 30 seconds, for free" (≈30s) + +| Time | On screen | Voice-over / caption | +| --- | --- | --- | +| 0–3s | Close-up of a messy printed spreadsheet covered in crossings-out | **Hook:** "If your seating chart looks like this…" | +| 3–7s | Hard cut to Trousseau Seating: an empty room drawn to scale | "Draw your actual room. To scale, so if it doesn't fit here, it won't fit on the day." | +| 7–13s | Drag three round tables in; drag guests onto seats; dietary counts tick up on the right | "Drag people onto seats. It counts the vegetarians for you." | +| 13–18s | Add a "keep apart" rule between two guests, then seat them together: a warning appears | "Uncle and ex-uncle? Tell it to keep them apart. It'll warn you." | +| 18–25s | Open Place cards, press **Use the room**: a sheet of cards appears with table numbers | "Then print your place cards. The table numbers are already on them." | +| 25–30s | End card | "Free. No ads. No sign-up. Link in bio." | + +**Caption:** Seating chart done before the kettle boils ☕ Free, no sign-up, +no ads. #weddingplanning #seatingchart #diywedding #weddingtok +#budgetwedding + +--- + +### Script 2: "The ceremony's running late. Watch the whole day fix itself" (≈40s) + +| Time | On screen | Voice-over / caption | +| --- | --- | --- | +| 0–3s | Face to camera, or text on black | **Hook:** "Your ceremony just moved 20 minutes. How many things do you have to change?" | +| 3–8s | A paper run sheet with times, being crossed out and rewritten | "Normally? All of them." | +| 8–15s | Trousseau Timeline: lanes for the day, suppliers, transport; the ceremony block is pinned | "In Trousseau, you pin the things with fixed times, and everything else follows." | +| 15–23s | Drag the ceremony 20 minutes later: drinks, photos and speeches slide along; a red collision appears at the curfew | "Move the ceremony and the day moves with it. And it tells you what now runs past the venue's curfew." | +| 23–30s | Mark the drinks reception as squeezable; the collision clears as drinks shrink | "Let the drinks run shorter, and it fixes itself." | +| 30–35s | Open Delegation: jobs show their new times | "Everyone's job sheet updates too." | +| 35–40s | End card | "Free wedding planner. No ads, ever. Link in bio." | + +**Caption:** Wedding day timeline that fixes itself when things move ⏰ +#weddingtimeline #weddingplanning #weddingday #dayofcoordinator +#weddingtok + +--- + +### Script 3: "What your planner doesn't want you to know: the day in your pocket" (≈35s) + +> Tone note: the hook is playful. Don't let the video imply that planners are +> bad, since Trousseau has a planner role and planners are an audience. +> Alternative hook if this feels off-brand: "The one thing to have on your +> phone on your wedding day." + +| Time | On screen | Voice-over / caption | +| --- | --- | --- | +| 0–3s | Phone in hand, venue-style background, "No Service" in the status bar | **Hook:** "The venue has no signal. Where's your run sheet?" | +| 3–10s | The Binder on the phone: "Now: Drinks reception · Next: Speeches 16:30" | "The Binder shows what's on now and what's next, even offline." | +| 10–17s | Scroll the running order; tap a supplier and their phone number appears | "The whole running order, and who to ring when the florist is lost." | +| 17–24s | Search a guest's name and their table appears | "Someone forgot their table? Search, and there it is." | +| 24–30s | Quick cut to the laptop: the same day in Timeline | "It's the same plan you made on your laptop. Nothing to copy over." | +| 30–35s | End card | "Free. Private. No sign-up. Link in bio." | + +**Caption:** Your wedding day, in your pocket, no signal needed 📱 +#weddingday #weddingplanning #weddinghacks #weddingtok #bridetobe +#groomtobe + +--- + +## 3. Short posts for reuse + +**Mastodon / Fediverse (≤500 chars):** +> I built Trousseau, a free, AGPL wedding planner where every tool shares +> one document. Seat your guests and one click puts the table numbers on the +> place cards; move the ceremony and the day moves with it. Local-first (IndexedDB, no account), +> self-hostable, and no analytics on your own instance. +> +> https://github.com/JFrusher/Trousseau +> +> #selfhosted #opensource #localfirst + +**Bluesky / X one-liner:** +> Wedding apps keep three copies of your guest list. Trousseau keeps one. +> Free, open source, no sign-up: trousseau-suite.vercel.app diff --git a/marketing/landing-page/index.html b/marketing/landing-page/index.html new file mode 100644 index 0000000..20daeec --- /dev/null +++ b/marketing/landing-page/index.html @@ -0,0 +1,499 @@ + + + + + + Trousseau: the free, open-source wedding planner + + + + + + + + + + + + + + +
    + +
    + +
    + + +
    +
    +

    Free forever · Open source · No sign-up

    +

    + Plan the whole wedding.
    Without the tools disagreeing. +

    +

    + Seat your guests and one click puts every table number on the place cards. Move the ceremony and the whole day moves with it. + Eleven planning tools, one wedding, and your guest list never has to leave your laptop. +

    + +

    No account, no password, no card. Open it and start.

    +
    +
    + The Trousseau front page, showing one wedding, its tools, and what is left to do +
    +
    + + +
    +
    +
    +

    📄

    +

    One wedding, one document

    +

    Most apps keep a separate copy of your guest list in every feature. Trousseau keeps one, and every tool reads from it.

    +
    +
    +

    🔁

    +

    Change it once

    +

    Correct a name and it's corrected on the seating plan, the place cards and the job sheets. Nothing to retype.

    +
    +
    +

    🚩

    +

    Catches what falls between

    +

    Two dates for one wedding, a table over capacity, a confirmed guest with no seat: flagged before the day, not on it.

    +
    +
    +
    + + +
    +
    +

    Try the idea right here

    +

    These little demos work the same way the real tools do. The real ones are free, one click away.

    +
    + +
    + + + +
    + + +
    +

    Pick a guest, then pick a table. Their place card takes the table from the room, and the dietary count keeps up.

    +
    +
    +

    Guests

    +
      +
      +
      +

      The room

      +
      + +
      +
      +

      Place card

      +
      +

      Pick a guest

      +

      –

      +
      +

      Seated so far

      +

      +
      +
      +
      + + + + + + + + +
      +

      👥

      Guests

      Import from Joy, Zola, The Knot or any spreadsheet. Preview before anything is saved.

      +

      🪑

      Seating

      Your room, to scale. Keep-together and keep-apart rules. Dietary counts as you go.

      +

      💌

      Place cards

      Your design, table numbers filled in. Refuses to print a card with a missing font.

      +

      🕒

      Timeline

      Collisions, curfew, travel time between places, and golden hour for photos.

      +

      📋

      Delegation

      Jobs for your helpers, and a printed sheet for each person.

      +

      📷

      Group shots

      The family photo list, built from who's related to whom.

      +

      💍

      Ceremony

      The processional, order of service, music and readings.

      +

      📱

      Binder

      The day on your phone: now, next, who to ring. Works without signal.

      +
      +

      Plus 📦 Boxes, 🍷 Bar, 💷 Money, ✅ Checklist, and a one-click PDF pack of the whole day.

      +
      + + +
      +
      +

      And here's the real thing

      +

      Recorded in the app, with the example wedding from the guided tour.

      +
      +
      +
      + +
      Seat a guest. One click in Place cards, and her card has her table.
      +
      +
      + +
      Move the ceremony. The day follows, and it tells you what no longer fits.
      +
      +
      +
      + +
      The Binder. The day on your phone, with or without signal.
      +
      +
      + + +
      +
      +

      "Free" isn't the same as free

      +

      Commercial wedding apps have to make money somewhere. Trousseau doesn't, so there's nothing to sell you.

      +
      + + + + + + + + + + + + + + + + + + +
      Typical commercial wedding appTrousseau
      Paid for byAdverts, vendor leads, registry commissions, upsellsNothing. There is no paid tier, and never will be
      To get startedCreate an accountOpen it. No account needed
      Your guest list livesOn their serversIn your browser, unless you choose to sync
      Seating ↔ cards ↔ run sheetSeparate features, separate copiesOne document, so nothing is typed twice
      Place cardsOften sold as printed stationeryPrint your own, table numbers included
      Take your data with youVariesThe whole wedding, as one file
      Run your own copyNoYes. Open source (AGPL)
      RSVPs and a wedding websiteUsually, yesNo. Keep using Joy or similar, and import the RSVPs
      +
      +

      "Typical" describes how the category is commonly funded, not any one product. Check each app's own terms.

      +
      +
      + + +
      +

      The pledge

      +

      Written into the licence and the code, not just this page.

      +
        +
      1. i.

        Free, forever.

        No paid tier, no premium features, no trial. The AGPL licence means nobody can take it closed and charge for it.

      2. +
      3. ii.

        Your guests are not the product.

        No adverts, no vendor leads, and guest data is never sold or shared for marketing. Account sync uses the storage provider described in the privacy policy.

      4. +
      5. iii.

        Nobody browses your wedding.

        There's no admin panel and no support login. Synced weddings are encrypted at rest and walled off by database rules. That isn't end-to-end encryption, and the privacy policy says so plainly.

      6. +
      7. iv.

        Counting pages, not people.

        The hosted site counts page visits with no cookie, cutting every address down to the page first. A copy hosted off Vercel counts nothing at all.

      8. +
      9. v.

        You can always leave.

        Download the whole wedding as one file, in an open format. Delete your account and your data goes with it.

      10. +
      +
      + + +
      +
      +
      +

      Run your own copy

      +

      Your domain, your database, and no analytics. On your own machine it needs no backend at all. Add a free Supabase project when you want accounts and sync between devices.

      +
        +
      • ✓ Next.js app, runs anywhere Node 20+ does
      • +
      • ✓ Optional Supabase: Postgres with row-level security
      • +
      • ✓ Every command tested on a fresh clone
      • +
      • ✓ No Docker image yet. Tell us if you'd use one
      • +
      + Read the self-hosting guide → +
      +
      # local-only: no backend, no account
      +git clone https://github.com/JFrusher/Trousseau.git
      +cd Trousseau
      +npm ci
      +npm run build
      +npm run dev -w suite
      +
      +# → http://localhost:3000
      +
      +
      + + +
      +

      Your wedding, in one place.

      +

      There's a guided tour with a full example wedding, if you'd like to look around first.

      + +
      +
      + + + + + +