Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
52ef1f6
Stop open tools writing their old copy over the Data panel
claude Sep 28, 2026
506442b
Write down the expansion: planners, setup, and the windows around the…
claude Sep 28, 2026
917aa20
Say when a save fails, and put every dialog on the native one
claude Sep 28, 2026
bc14c32
Raise the smallest text from 11px to 12px
claude Sep 28, 2026
62b8d04
Give the wedding's names, date and venue one editor
claude Sep 28, 2026
9a5f069
One guest importer, and one meaning for a dietary requirement
claude Sep 28, 2026
be1dc3e
Name each side after the partners, not bride and groom
claude Sep 28, 2026
e9c4fef
Sign in without replacing anyone's wedding
claude Sep 28, 2026
1fbac09
Add planners: roles, and many weddings per account
claude Sep 28, 2026
e037bc2
Add setup: the two of you, guests, the room, then planning together
claude Sep 28, 2026
1dc664b
Move the guest link onto the account, keep it current, delete lib/sync
claude Sep 28, 2026
de7adcd
Tour every chapter; an example wedding worth touring
claude Sep 28, 2026
71b4ed6
Overview: the wedding's state, and a header that fits at 1024px
claude Sep 28, 2026
546cdde
Guests: the whole list, found, changed one at a time or many at once
claude Sep 28, 2026
2f9c4b7
Money: what the suppliers cost, what is paid, and what is still to pay
claude Sep 28, 2026
ffb4ef3
Checklist: what to have done before the day, each by a date
claude Sep 28, 2026
268b67f
Sync & history, and merging record by record
claude Sep 28, 2026
e13bb3d
Command palette: anything in the wedding, by name, from anywhere
claude Sep 28, 2026
05282d7
Weddings page: each client with what is next and what is owed
claude Sep 28, 2026
d172b78
A planner's library: designs kept to use again, with nobody in them
claude Sep 28, 2026
86fa624
Keep guests' names out of addresses; one name per supplier in the exa…
claude Sep 29, 2026
a4e5074
Plan: record that the Privacy Policy and Vercel Analytics disagree
claude Sep 29, 2026
5a4a9e5
Binder: the wedding on a phone, on the day, with or without signal
claude Sep 29, 2026
ea2a6ac
Suppliers' links: each supplier's own call sheet, with Confirm
claude Sep 29, 2026
c5440bc
Delegation reads and writes the live wedding
claude Sep 29, 2026
bf647d1
Place cards read and write the live wedding; fix S19
claude Sep 29, 2026
8e48229
Timeline reads and writes the live wedding
claude Sep 29, 2026
2b7038a
Seating reads and writes the live wedding; retire the copy guards
claude Sep 29, 2026
0206b2a
Seating in TypeScript: model, utilities, plan and store
claude Sep 29, 2026
dd72d0f
Seating in TypeScript: every component; floor tools work inside a room
claude Sep 29, 2026
99a1dce
One undo button, reading the wedding's history itself
claude Sep 29, 2026
c445691
Remove lib/seating's unused port; one seating rule to a pair
claude Sep 29, 2026
b0d54a6
Real-time sync and presence, in place of the 20-second poll
claude Sep 29, 2026
a93c7a2
Lift fast-uri to 3.1.8 for two high-severity advisories
claude Sep 29, 2026
07d9886
Binder: sign-out clears its ticked shots; errors never replace the ke…
claude Sep 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
40 changes: 40 additions & 0 deletions docs/PRODUCT-ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ instead (see subsystem F).
| 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 |
| 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 |
| L | Design language and shared kit | — | ⬜ planned — master plan, phase 0 |
| M | Windows around the tools: Overview, Guests, Money, Checklist, Sync & history, palette | E, L | ⬜ planned — master plan, phase 2 |
| N | Day-of binder and vendor links | E, J | ⬜ planned — master plan, phase 3 |
| O | One live document: tools stop keeping copies; real-time sync | — | ⬜ planned — master plan, phase 4 |

## Decisions log

Expand Down Expand Up @@ -118,6 +124,40 @@ against, not a discussion to reopen without a reason.
- **2026-09-02** — Accounts-to-weddings: one active wedding per account for
v1, no switcher UI. Additive-safe — can extend to multiple weddings per
account later without redesigning the model.
- **2026-09-28** — A **planner role**, alongside the two partners. An account
may be a partner in one wedding and a planner in any number; a wedding has
at most one planner in v1. This is the "extend later" the entry above left
room for. Detail in the
[master plan](superpowers/specs/2026-09-28-expansion-master-plan.md).
- **2026-09-28** — Sides are named after the partners, not "bride" and
"groom".
- **2026-09-28** — Phones get a read-only day-of binder; the editing tools
stay desktop.
- **2026-09-28** — RSVPs stay with Joy and similar services; Trousseau imports
the result through one importer that never unseats or silently deletes.
- **2026-09-28** — The guest link moves onto the account with no passphrase,
and stays current by itself once published. `lib/sync` goes when it does.
- **2026-09-28** — Privacy promise restated: data may leave the device; nobody
reads a couple's plans or their guests' names, and guest data never goes to
a third party.
- **2026-09-28** — Every tool converges on the live document (no private
copies, one undo). Seating goes last and becomes TypeScript as part of it.
Supersedes the incremental-only Tableaux migration for its store layer.
- **2026-09-28** — Signing in never replaces a wedding silently. A device
stores which account wedding it belongs to and what the two last agreed;
two different weddings with work in both are asked about (whole weddings,
not per part), and the one not chosen is kept as a copy on the device. A
wedding is created at sign-in, except on the way to an invite. Replaces the
offline write queue.
- **2026-09-28** — Roles: a wedding has up to two partners and one planner; an
account is a partner in one wedding and a planner in any number. The couple
sees who has access and can remove their planner. Each device holds one
wedding at a time and switching is a swap through `/open`, never a merge.
- **2026-09-28** — The guest link moved onto the account and keeps itself
current; the passphrase sync is deleted. The link's key is stored with the
wedding, so it is as readable to the server's operator as the wedding is —
stated in the Privacy Policy, which no longer describes the passphrase
system.

## Subsystem H — Guided tour & example wedding

Expand Down
556 changes: 556 additions & 0 deletions docs/superpowers/specs/2026-09-28-expansion-master-plan.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe("eventSchema", () => {
expect(parsed).toEqual({
date: "",
coupleNames: "",
partners: ["", ""],
venueName: "",
curfewMin: null,
utcOffsetMin: null,
Expand All @@ -29,6 +30,11 @@ describe("eventSchema", () => {
expect(parsed).toMatchObject({ hashtag: "#ab2026" });
});

it("holds the two partners by name", () => {
expect(eventSchema.parse({ partners: ["Alex", "Sam"] }).partners).toEqual(["Alex", "Sam"]);
expect(eventSchema.safeParse({ partners: ["Alex"] }).success).toBe(false);
});

it("rejects a field of the wrong type", () => {
expect(eventSchema.safeParse({ curfewMin: "late" }).success).toBe(false);
});
Expand Down
12 changes: 12 additions & 0 deletions src/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,19 @@ import { z } from "zod";
*/
export const eventSchema = z.looseObject({
date: z.string().default(""),
/**
* What the wedding is called on every title — "Alex & Sam". Written from
* `partners` by the one place they are edited, and kept so that everything
* printing a title can go on reading one string.
*/
coupleNames: z.string().default(""),
/**
* The two people getting married, each by the name the guests know them by.
* Their own names are what the suite calls each side of the family and each
* person in the group shots — "Alex's side", "Sam's mother" — rather than
* "bride" and "groom", which described one kind of couple.
*/
partners: z.tuple([z.string(), z.string()]).default(["", ""]),
venueName: z.string().default(""),
/** Minutes from the day's 00:00, as everywhere in Cadence. */
curfewMin: z.number().nullable().default(null),
Expand Down
204 changes: 101 additions & 103 deletions suite/app/(app)/account/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
"use client";

import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { Download, LogOut, Trash2, UserPlus } from "lucide-react";
import { browserClient } from "@/lib/accounts/browserClient";
import { Button, TextField } from "@/components/ui/controls";
import { Button } from "@/components/ui/controls";
import { useConfirm } from "@/components/ui/Confirm";
import { removeWeddingFromDevice } from "@/lib/store/removeFromDevice";
import { SignInFailed } from "@/components/shell/SignInFailed";
import { WeddingPeople } from "@/components/shell/WeddingPeople";
import { useTrousseauStore } from "@/lib/store/useTrousseauStore";
import type { WeddingListing } from "@/lib/accounts/handlers";

interface AccountState {
signedIn: boolean;
weddingId: string | null;
}
type AccountState = { signedIn: false } | { signedIn: true; me: string; weddings: WeddingListing[] };

interface Notice {
text: string;
Expand All @@ -27,76 +31,71 @@ async function readJson<T>(response: Response): Promise<T | null> {

export default function AccountPage() {
const [state, setState] = useState<AccountState | null>(null);
const [inviteEmail, setInviteEmail] = useState("");
const [notice, setNotice] = useState<Notice | null>(null);
const client = browserClient();
const [signinFailed, setSigninFailed] = useState(false);
useEffect(() => {
setSigninFailed(new URLSearchParams(window.location.search).get("signin") === "failed");
}, []);
const confirm = useConfirm();
// The wedding open on this device — the one "who has access" is about.
const open = useTrousseauStore((s) => s.weddingId);
const say = useCallback((text: string, tone: "ok" | "error") => setNotice({ text, tone }), []);

useEffect(() => {
if (!client) {
setState({ signedIn: false, weddingId: null });
setState({ signedIn: false });
return;
}
client
.auth.getUser()
.then(async ({ data }) => {
if (!data.user) {
setState({ signedIn: false, weddingId: null });
setState({ signedIn: false });
return;
}
// Ask which wedding this account already belongs to, rather than
// assuming none: without this, a returning member is offered "create
// your wedding" on every reload and can never reach the invite form.
const response = await fetch("/api/accounts/wedding");
const body = await readJson<{ weddingId?: string | null }>(response);
setState({ signedIn: true, weddingId: (response.ok && body?.weddingId) || null });
const response = await fetch("/api/accounts/weddings");
const body = await readJson<{ weddings?: WeddingListing[] }>(response);
setState({ signedIn: true, me: data.user.id, weddings: (response.ok && body?.weddings) || [] });
})
.catch(() => {
// Never leave the page on "Loading…" because a request threw.
setState({ signedIn: true, weddingId: null });
setNotice({ text: "Could not load your wedding. Please try again.", tone: "error" });
setState({ signedIn: false });
setNotice({ text: "Could not load your account. Please try again.", tone: "error" });
});
}, [client]);

async function createWedding() {
const response = await fetch("/api/accounts/wedding", { method: "POST" });
const body = await readJson<{ weddingId?: string; error?: string }>(response);
if (!response.ok) {
setNotice({ text: body?.error ?? "Could not create a wedding.", tone: "error" });
return;
}
setState((prev) => (prev ? { ...prev, weddingId: body?.weddingId ?? null } : prev));
setNotice({ text: "Wedding created.", tone: "ok" });
}

async function invitePartner() {
if (!state?.weddingId) return;
const response = await fetch("/api/accounts/invite", {
const response = await fetch("/api/accounts/weddings", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: inviteEmail }),
body: JSON.stringify({ role: "partner" }),
});
const body = await readJson<{ error?: string }>(response);
const body = await readJson<{ weddingId?: string; error?: string }>(response);
if (!response.ok) {
setNotice({ text: body?.error ?? "Could not send the invite.", tone: "error" });
setNotice({ text: body?.error ?? "Could not create a wedding.", tone: "error" });
return;
}
setNotice({ text: `Invite sent to ${inviteEmail}.`, tone: "ok" });
setInviteEmail("");
// A full load: sync starts as the app loads, and only now is there a
// wedding for it to start with. Without it nothing reached the account
// until the next visit.
window.location.reload();
}

async function signOut() {
const [signingOut, setSigningOut] = useState(false);

async function signOut(remove: boolean) {
await client?.auth.signOut();
// Shared computers exist. Kept, the wedding stays on this device and
// picks up where it left off at the next sign-in.
if (remove) await removeWeddingFromDevice();
window.location.href = "/login";
}

async function deleteAccount() {
if (!window.confirm("Delete your account? Your wedding data goes with it. This cannot be undone.")) {
return;
}
const confirmed = await confirm({
title: "Delete your account?",
body: "You leave every wedding you are on. Each one stays with anyone else on it; any you are the last one on is deleted. This cannot be undone.",
action: "Delete my account",
tone: "danger",
});
if (!confirmed) return;
const response = await fetch("/api/accounts/delete", { method: "POST" });
const body = await readJson<{ error?: string }>(response);
if (!response.ok) {
Expand All @@ -120,22 +119,7 @@ export default function AccountPage() {
<p className="mt-6 text-slate">Loading…</p>
) : !state.signedIn ? (
<div className="mt-6 space-y-4">
{signinFailed && (
<div className="rounded border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-charcoal">
<p className="font-medium">That link did not sign you in.</p>
<p className="mt-1 text-slate">
Two things stop one working. It has to be opened in the same browser that asked
for it — a phone, or an email app opening its own window, will not do. And it has
to come back to the same address you started on: a link that returns you to a
different one cannot carry your session, and will not show your wedding either.
</p>
<p className="mt-1 text-slate">
You are on <strong>{typeof window === "undefined" ? "" : window.location.origin}</strong>.
If that is not where you were planning, go back to the address you were using —
your wedding is stored per address and is still there, untouched.
</p>
</div>
)}
<SignInFailed />
<p className="text-slate">Sign in to manage your wedding account.</p>
</div>
) : (
Expand All @@ -150,60 +134,74 @@ export default function AccountPage() {
</p>
)}

{!state.weddingId ? (
{state.weddings.length === 0 ? (
<section className="space-y-3">
<p className="text-sm text-slate">
You haven&rsquo;t created a wedding yet — this is where you and your partner will share one.
You&rsquo;re not on a wedding yet. If your partner invited you, open the link in
their email instead — one of the couple is on one wedding at a time, so starting
your own now means you cannot join theirs.
</p>
<Button onClick={() => void createWedding()} tone="primary" icon={UserPlus}>
Create your wedding
<Button onClick={() => void createWedding()} icon={UserPlus}>
Start our wedding
</Button>
</section>
) : (
<section className="space-y-3 border-t border-charcoal/10 pt-6 first:border-t-0 first:pt-0">
<h2 className="text-xs tracking-widest text-slate uppercase">Invite your partner</h2>
<form
onSubmit={(event) => {
event.preventDefault();
void invitePartner();
}}
className="space-y-3"
>
<TextField
label="Their email"
type="email"
value={inviteEmail}
onChange={setInviteEmail}
placeholder="partner@example.com"
/>
<Button onClick={() => void invitePartner()} tone="primary" icon={UserPlus} disabled={!inviteEmail}>
Send invite
</Button>
</form>
</section>
)}

{state.weddingId && (
<section className="space-y-3 border-t border-charcoal/10 pt-6">
<h2 className="text-xs tracking-widest text-slate uppercase">Your data</h2>
<p className="text-sm text-slate">
Download everything saved to your account as one file — guests, seating, the day,
the crew and the stationery. It opens in Trousseau anywhere, including your own
copy if you ever run one.
Planning weddings for clients? <Link href="/weddings" className="underline">Your weddings</Link>
</p>
<Button
onClick={() => window.location.assign("/api/documents/export")}
icon={Download}
>
Download my wedding
</Button>
</section>
) : (
<>
{state.weddings.length > 1 || open === null ? (
<p className="text-sm text-slate">
You are on {state.weddings.length === 1 ? "one wedding" : `${state.weddings.length} weddings`}.{" "}
<Link href="/weddings" className="underline">
{open === null ? "Open one" : "See them all"}
</Link>
</p>
) : null}
{open !== null ? (
<>
<WeddingPeople weddingId={open} me={state.me} onNotice={say} />
<section className="space-y-3 border-t border-charcoal/10 pt-6">
<h2 className="text-xs tracking-widest text-slate uppercase">Your data</h2>
<p className="text-sm text-slate">
Download the wedding open here as one file — guests, seating, the day, the
crew and the stationery. It opens in Trousseau anywhere, including your own
copy if you ever run one.
</p>
<Button
onClick={() => window.location.assign(`/api/documents/export?wedding=${encodeURIComponent(open)}`)}
icon={Download}
>
Download this wedding
</Button>
</section>
</>
) : null}
</>
)}

<section className="flex flex-wrap gap-2 border-t border-charcoal/10 pt-6">
<Button onClick={() => void signOut()} icon={LogOut}>
Sign out
</Button>
{signingOut ? (
<div className="w-full space-y-3">
<p className="text-sm text-slate">
Keep the wedding on this device, or remove it? On a computer that isn&rsquo;t
yours, remove it — it stays on your account either way.
</p>
<div className="flex flex-wrap gap-2">
<Button onClick={() => void signOut(false)} icon={LogOut}>
Sign out and keep it here
</Button>
<Button onClick={() => void signOut(true)} icon={Trash2} tone="danger">
Sign out and remove it
</Button>
<Button onClick={() => setSigningOut(false)}>Cancel</Button>
</div>
</div>
) : (
<Button onClick={() => setSigningOut(true)} icon={LogOut}>
Sign out
</Button>
)}
<Button onClick={() => void deleteAccount()} tone="danger" icon={Trash2}>
Delete my account
</Button>
Expand Down
11 changes: 11 additions & 0 deletions suite/app/(app)/checklist/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { ChecklistPage } from "@/components/checklist/ChecklistPage";

export const metadata: Metadata = {
title: "Checklist",
description: "What to have done before the day, each with a date to be done by.",
};

export default function Checklist() {
return <ChecklistPage />;
}
Loading
Loading