diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 67f1c989..ee19d674 100644
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -8,9 +8,9 @@
# git config core.hooksPath .githooks
set -e
-if [ -f data/wedding.trousseau.json ]; then
+if [ -f data/wedding.knotwork.json ]; then
if [ -f dist/index.js ]; then
- node scripts/validate-wedding.mjs data/wedding.trousseau.json
+ node scripts/validate-wedding.mjs data/wedding.knotwork.json
else
echo "pre-commit: skipping validation, dist/ is not built (npm run build)"
fi
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a877deae..4165a605 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,10 +17,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
@@ -35,10 +35,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v7
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
diff --git a/.gitignore b/.gitignore
index bf709a30..a0fac416 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,8 +4,10 @@ dist/
# Personal wedding data — never commit.
# Anchored to the root: data/ is the canonical home and is handled by DVC,
-# which writes its own data/.gitignore. An unanchored *.trousseau.json here
+# which writes its own data/.gitignore. An unanchored *.knotwork.json here
# would match that file too, and `dvc add` refuses a git-ignored path.
+/*.knotwork.json
+# Exported before the rename. Still real weddings, still never committed.
/*.trousseau.json
unpacked/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 33062e2c..72f234c4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,6 +1,6 @@
-# Contributing to Trousseau
+# Contributing to Knotwork
-Thank you for thinking about it. Trousseau is used by real couples planning
+Thank you for thinking about it. Knotwork 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.
@@ -55,7 +55,7 @@ who was bringing the cake stand" beats "add a cake stand field". Then:
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
+Knotwork imports the result), a paid tier of any kind, and an admin panel
that can browse weddings.
---
@@ -71,8 +71,8 @@ that can browse weddings.
### Install and run
```sh
-git clone https://github.com/JFrusher/Trousseau.git
-cd Trousseau
+git clone https://github.com/JFrusher/Trousseau.git Knotwork
+cd Knotwork
npm ci # installs the root package and the suite workspace
npm run build # builds the contract package into dist/
@@ -80,12 +80,12 @@ 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
+`"@jfrusher/knotwork": "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'
+Module not found: Can't resolve '@jfrusher/knotwork'
```
If you see that, run `npm run build` at the root and try again.
@@ -184,7 +184,7 @@ of their own wedding.
### Changing the contract package
-`src/` is published to npm as `@jfrusher/trousseau` and other tools may
+`src/` is published to npm as `@jfrusher/knotwork` 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.
diff --git a/LICENSE b/LICENSE
index 39c2f46a..cdbee5ff 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,22 +1,22 @@
-Trousseau is released under two licences, because this repository holds two
+Knotwork is released under two licences, because this repository holds two
different things.
- The contract package — @jfrusher/trousseau
+ The contract package — @jfrusher/knotwork
------------------------------------------
MIT. See LICENSE-MIT.
This is the published npm package: the schemas and the file format that
describe a wedding. It is deliberately permissive so that a tool nobody has
written yet can depend on it, which is the entire point of the format
- existing. If you installed @jfrusher/trousseau from npm, this is the licence
+ existing. If you installed @jfrusher/knotwork from npm, this is the licence
that applies to you, and you can stop reading here.
The application — everything in suite/
--------------------------------------
GNU Affero General Public License v3.0 or later. See LICENSE-AGPL.
- This is Trousseau itself: the five tools, the shell, the sync and account
- layers. The AGPL is chosen deliberately. Trousseau is free and always will
+ This is Knotwork itself: the five tools, the shell, the sync and account
+ layers. The AGPL is chosen deliberately. Knotwork is free and always will
be, and the AGPL is what stops someone running a paid, closed fork of the
hosted service against the intent of everyone who worked on the free one.
Run it yourself, change it, host it for your friends — but if you host a
diff --git a/README.md b/README.md
index 9b034ae5..9fd6e63d 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,13 @@
-# 💍 Trousseau
+# 💍 Knotwork
### 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.**
+Formerly Trousseau.
+
[](LICENSE)
[](https://github.com/JFrusher/Trousseau/stargazers)
[](https://github.com/JFrusher/Trousseau/actions/workflows/ci.yml)
@@ -13,13 +15,13 @@
[](https://trousseau-suite.vercel.app)
[](CONTRIBUTING.md)
-[**Open Trousseau →**](https://trousseau-suite.vercel.app) ·
+[**Open Knotwork →**](https://trousseau-suite.vercel.app) ·
[Run your own copy](#-run-your-own-copy) ·
[How it works](#-how-it-works) ·
[Roadmap](ROADMAP.md) ·
[Contribute](CONTRIBUTING.md)
-
+
@@ -38,7 +40,7 @@ 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**.
-Trousseau is the opposite of that:
+Knotwork is the opposite of that:
- **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
@@ -147,8 +149,8 @@ error reporting only runs if you set a Sentry DSN.
### Local only: no backend, no account
```sh
-git clone https://github.com/JFrusher/Trousseau.git
-cd Trousseau
+git clone https://github.com/JFrusher/Trousseau.git Knotwork
+cd Knotwork
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
@@ -201,8 +203,8 @@ how to check your instance actually works rather than merely starting.
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.
+ thing as one `.knotwork.json` file. That is the same format the app uses,
+ so it opens straight back into Knotwork, 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.
@@ -302,7 +304,7 @@ suite/ the web application (AGPL-3.0-or-later)
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)
+src/ the data contract, published as @jfrusher/knotwork (MIT)
supabase/ database migrations
docs/ self-hosting, building a tool, and dated specs and plans
```
@@ -320,7 +322,7 @@ anyone's personal details.
- **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
+- **Want to build a tool?** A job nothing in Knotwork does for you yet is the
best reason to. **[docs/BUILDING-A-TOOL.md](docs/BUILDING-A-TOOL.md)** walks
through the whole journey.
@@ -336,12 +338,12 @@ milestones and the issues to pick up.
## 🌱 Where this came from
-Trousseau was built for one specific wedding. That is the only reason its
+Knotwork 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.
-That wedding has happened. Trousseau is now being built for other couples,
+That wedding has happened. Knotwork 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.
@@ -355,7 +357,7 @@ Two licences, because this repository holds two different things.
**[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)**.
+- The **contract package**, `@jfrusher/knotwork`, 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.
@@ -366,6 +368,6 @@ 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.**
+**If Knotwork saves you an evening with a spreadsheet, a ⭐ helps other couples find it.**
diff --git a/ROADMAP.md b/ROADMAP.md
index 5e08a873..ddbc17cf 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,6 +1,6 @@
# Roadmap
-Where Trousseau is, and where it could go next. This page is the short
+Where Knotwork 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/`.
@@ -18,7 +18,7 @@ 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`).
+ a published MIT data contract (`@jfrusher/knotwork`).
- ✅ **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
@@ -47,7 +47,7 @@ is retyped and nothing disagrees.
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
+- ✅ **Download my wedding** as a single `.knotwork.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.
@@ -102,7 +102,7 @@ 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
+- **RSVP collection.** Joy and similar services do this well. Knotwork
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
diff --git a/data/.gitignore b/data/.gitignore
index 9ba45848..2a7bdf74 100644
--- a/data/.gitignore
+++ b/data/.gitignore
@@ -1,3 +1,5 @@
+/wedding.knotwork.json
+# The same file under its name before the rename, still on existing checkouts.
/wedding.trousseau.json
/exports
diff --git a/data/wedding.trousseau.json.dvc b/data/wedding.knotwork.json.dvc
similarity index 66%
rename from data/wedding.trousseau.json.dvc
rename to data/wedding.knotwork.json.dvc
index f3bb7607..30c67263 100644
--- a/data/wedding.trousseau.json.dvc
+++ b/data/wedding.knotwork.json.dvc
@@ -1,5 +1,5 @@
-outs:
-- md5: 3d1e6a1f0d401cd24bc3a70033d28f6f
- size: 456154
- hash: md5
- path: wedding.trousseau.json
+outs:
+- md5: 3d1e6a1f0d401cd24bc3a70033d28f6f
+ size: 456154
+ hash: md5
+ path: wedding.knotwork.json
diff --git a/docs/BUILDING-A-TOOL.md b/docs/BUILDING-A-TOOL.md
index 4f6662a3..8951d3f3 100644
--- a/docs/BUILDING-A-TOOL.md
+++ b/docs/BUILDING-A-TOOL.md
@@ -1,6 +1,6 @@
# Building a tool
-Trousseau grows one tool at a time, and most of the good ideas come from
+Knotwork grows one tool at a time, and most of the good ideas come from
someone planning their own wedding and finding a job nothing did for them.
That is how the processional planner, Boxes and the Bar arrived. This guide
covers the whole journey, from "would this be a tool?" to a merged pull request
@@ -51,7 +51,7 @@ these instead:
- a check (see [What is left](#what-is-left-checks-between-tools));
- a view inside an existing tool.
-**4. Would many weddings use it?** Trousseau is for couples and the planners
+**4. Would many weddings use it?** Knotwork is for couples and the planners
who help them. An idea that fits one wedding's quirk is better as a note in an
existing tool. An idea a good share of couples would switch on is a tool.
New tools start switched off (see [the rules](#3-the-rules-every-tool-keeps)),
@@ -206,9 +206,9 @@ Bar is the example throughout. Its id is `bar`, and its pieces are in
### The data
-1. **The contract** (`src/`, published as `@jfrusher/trousseau`):
+1. **The contract** (`src/`, published as `@jfrusher/knotwork`):
- add `barSchema = z.looseObject({}).default(() => ({}))` to `src/slices.ts`;
- - add `"bar"` to `SLICE_NAMES` and `trousseauSchema` in `src/envelope.ts`;
+ - add `"bar"` to `SLICE_NAMES` and `knotworkSchema` in `src/envelope.ts`;
- export it from `src/index.ts`;
- update the list in `src/envelope.test.ts`.
@@ -350,7 +350,7 @@ Planners reuse work across weddings. To let them keep your tool's work:
### The example wedding
-`suite/public/fixtures/example-wedding.trousseau.json` is what the tour and
+`suite/public/fixtures/example-wedding.knotwork.json` is what the tour and
the tests load, and it shows every tool. A test enforces that. Add your id to
`tools.shown` and give it believable data.
diff --git a/docs/DATA.md b/docs/DATA.md
index 7984524e..d9a07489 100644
--- a/docs/DATA.md
+++ b/docs/DATA.md
@@ -1,6 +1,6 @@
# The wedding data, and where it lives
-Trousseau holds the canonical wedding. The four apps hold working copies.
+Knotwork holds the canonical wedding. The four apps hold working copies.
Git carries the schemas, the scripts and the **pointers**. DVC carries the data
itself, to a private OneDrive folder. Nothing with a guest's name on it has ever
@@ -8,8 +8,8 @@ reached a public repo, and this arrangement is what keeps that true.
```
data/
- wedding.trousseau.json <- canonical. DVC-tracked, git-ignored.
- wedding.trousseau.json.dvc <- the pointer. 4 lines of md5, committed.
+ wedding.knotwork.json <- canonical. DVC-tracked, git-ignored.
+ wedding.knotwork.json.dvc <- the pointer. 4 lines of md5, committed.
exports/ <- derived PDFs and CSVs. DVC-tracked, git-ignored.
exports.dvc <- the pointer. Committed.
.gitignore <- written by DVC. Committed.
@@ -54,7 +54,7 @@ schema and exact bytes together.
## Setting up a new device
```sh
-git clone https://github.com/JFrusher/Trousseau && cd Trousseau
+git clone https://github.com/JFrusher/Trousseau Knotwork && cd Knotwork
npm install
# The remote URL is per-device and deliberately not committed — it names a path
@@ -96,7 +96,7 @@ directory:
```sh
node scripts/bundle.mjs pack ~/Desktop/state.json ~/Desktop/day.cadence.json \
- -o data/wedding.trousseau.json
+ -o data/wedding.knotwork.json
```
`dvc pull` only ever writes `data/`. The apps' own working copies are not
diff --git a/docs/PRODUCT-ROADMAP.md b/docs/PRODUCT-ROADMAP.md
index 75a85d04..92875df2 100644
--- a/docs/PRODUCT-ROADMAP.md
+++ b/docs/PRODUCT-ROADMAP.md
@@ -1,21 +1,21 @@
-# Trousseau — product roadmap
+# Knotwork — product roadmap
Status: **living document** — updated as decisions land, not a one-shot spec.
Started: 2026-09-02.
-This is the record of turning Trousseau from a tool built for one wedding into
+This is the record of turning Knotwork from a tool built for one wedding into
a real product other couples can use. It captures the vision, the
decomposition into independent subsystems, decisions already made, and open
questions per subsystem. Each subsystem gets its own dated design spec in
`docs/superpowers/specs/` once it's actually designed — this document links
out to those rather than duplicating them.
-Baseline: `docs/superpowers/specs/2026-09-02-trousseau-architecture-audit.md`
+Baseline: `docs/superpowers/specs/2026-09-02-knotwork-architecture-audit.md`
— the architecture audit that preceded this pivot decision.
## Vision
-Trousseau was built for one wedding, with the constraints that came from that
+Knotwork was built for one wedding, with the constraints that came from that
being honest: real guest names, four tools that must not overwrite each
other, a date that doesn't move. The wedding has now happened. The decision
is to keep building this — as a real product for other couples, not a
@@ -59,7 +59,7 @@ instead (see subsystem F).
Settled answers, in the order they were made. Each entry is a fact to build
against, not a discussion to reopen without a reason.
-- **2026-09-02** — Scope: Trousseau becomes a real multi-tenant product for
+- **2026-09-02** — Scope: Knotwork becomes a real multi-tenant product for
couples generally, not a single-wedding tool being wound down.
- **2026-09-02** — Tableaux: its "former standalone SaaS product" scar tissue
(dead `planId`, references to a server that no longer exists, JS/no-schema
@@ -75,7 +75,7 @@ against, not a discussion to reopen without a reason.
not extended or productionized.
- **2026-09-08** — Clarifying the above: "dropped" means it is not the
product's sync story, **not** that the tooling is gone. `.githooks/pre-commit`
- still runs the cross-slice validator over `data/wedding.trousseau.json` on
+ still runs the cross-slice validator over `data/wedding.knotwork.json` on
every commit and still catches real problems, and `scripts/sync.mjs` is still
the maintainer's own two-machine workflow. Both stay. Deleting working
tooling because a decision log calls it superseded is how you lose something
@@ -134,7 +134,7 @@ against, not a discussion to reopen without a reason.
"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
+- **2026-09-28** — RSVPs stay with Joy and similar services; Knotwork 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.
@@ -162,7 +162,7 @@ against, not a discussion to reopen without a reason.
## Subsystem H — Guided tour & example wedding
-**Why:** Trousseau opens on an empty document with five unfamiliar tools and
+**Why:** Knotwork opens on an empty document with five unfamiliar tools and
nothing explaining that they share one wedding — which is the entire point of
the product and is invisible until you have done enough work to notice it. The
README explains it; almost nobody reads a README before using a web app.
@@ -337,7 +337,7 @@ executed in full on 2026-09-07 (branch `licensing-selfhosting`).
**Licence decision refined during implementation.** The spec said to relicense
every `package.json` including the root. The root package *is*
-`@jfrusher/trousseau`, published to npm, and the founding design expects a
+`@jfrusher/knotwork`, published to npm, and the founding design expects a
fifth app to depend on it — AGPL there would make it unadoptable while adding
nothing, since the stated aim (stopping a paid fork of the hosted service) is
served by AGPL on the application alone. **So: the application in `suite/` is
diff --git a/docs/SELF-HOSTING.md b/docs/SELF-HOSTING.md
index d54461c8..6cf26a44 100644
--- a/docs/SELF-HOSTING.md
+++ b/docs/SELF-HOSTING.md
@@ -1,6 +1,6 @@
-# Running your own Trousseau
+# Running your own Knotwork
-Trousseau is free software and this is a genuinely supported way to use it, not
+Knotwork is free software and this is a genuinely supported way to use it, not
a theoretical one. Every command below was run on a fresh clone before it was
written down.
@@ -16,7 +16,7 @@ Two pieces, licensed differently (see [`LICENSE`](../LICENSE)):
- The **application** in `suite/` — a Next.js app. AGPL-3.0-or-later. If you
host a modified version for other people, they are entitled to your source.
- The **contract package** at the repo root, published as
- `@jfrusher/trousseau`. MIT. It is the schemas and the file format.
+ `@jfrusher/knotwork`. MIT. It is the schemas and the file format.
## Requirements
@@ -32,8 +32,8 @@ Two pieces, licensed differently (see [`LICENSE`](../LICENSE)):
**The order matters, and getting it wrong is the most common way to fail:**
```sh
-git clone
-cd Trousseau
+git clone Knotwork
+cd Knotwork
npm install # the contract package's dependencies
npm run build # builds dist/ — do not skip this
@@ -44,7 +44,7 @@ npm install
### Why `npm run build` comes first
-`suite/package.json` depends on `"@jfrusher/trousseau": "file:.."`, which
+`suite/package.json` depends on `"@jfrusher/knotwork": "file:.."`, which
resolves to the root's `dist/` directory. A fresh clone has no `dist/`, and
`npm install` does not create one — only `npm run build` does.
@@ -53,7 +53,7 @@ arrives later, and does not mention any of the above:
```
Error: Turbopack build failed with 4 errors:
-Error: Module not found: Can't resolve '@jfrusher/trousseau'
+Error: Module not found: Can't resolve '@jfrusher/knotwork'
```
If you see that, you are in the right place: run `npm run build` in the repo
@@ -105,6 +105,39 @@ automatically and it is used to build absolute URLs for magic links and guest
links. On another host you may need an equivalent — see `originOf()` in
`suite/lib/env.ts`.
+### Sign-in: email code, Google and Apple
+
+Sign-in is a six-digit email code, or Google or Apple. None of it needs an
+environment variable in this app — the provider credentials live in Supabase.
+All of it is configured in the Supabase dashboard:
+
+1. **Authentication → URL Configuration.** Set **Site URL** to your production
+ origin, and add every origin you sign in from to **Redirect URLs** with a
+ wildcard, because the callback carries `?next=`:
+ `https://your-host/**` and `http://localhost:3000/**`.
+2. **Authentication → Email Templates → Magic Link.** The email must show the
+ code: include `{{ .Token }}` in the template.
+3. **Google** — in Google Cloud Console, create an OAuth client ID (type *Web
+ application*). Authorised redirect URI:
+ `https://.supabase.co/auth/v1/callback`. Paste the **Client ID**
+ and **Client Secret** into Supabase → Authentication → Providers → Google
+ and enable it.
+4. **Apple** — in the Apple Developer portal:
+ - an **App ID** with *Sign in with Apple* enabled;
+ - a **Services ID** (this is the client ID Supabase asks for), with *Sign in
+ with Apple* configured: domain `.supabase.co`, return URL
+ `https://.supabase.co/auth/v1/callback`;
+ - a **Key** with *Sign in with Apple* enabled — download the `.p8` and note
+ its **Key ID** and your **Team ID**.
+
+ Generate the client secret (a JWT signed with the `.p8`; Supabase's Apple
+ provider page links a generator) and paste the Services ID and secret into
+ Supabase → Authentication → Providers → Apple. **The secret expires after six
+ months at most** — put renewing it in a calendar, or Apple sign-in stops.
+
+A provider left disabled answers the button with Supabase's "provider is not
+enabled" error, shown on the sign-in page.
+
## 4. Apply the migrations
Every file in `supabase/migrations/`, in filename order, skipping none. Later
@@ -165,12 +198,13 @@ Then, in the browser:
1. Open the app. The five tools load and you can add a guest.
*(Local storage works.)*
-2. Go to `/account` and sign in with a magic link.
- *(Accounts and email work.)*
+2. Go to `/login` and sign in with an emailed code, then with Google and Apple
+ if you enabled them.
+ *(Accounts, email and providers work.)*
3. Add a guest, then reload. It is still there.
*(Cloud sync works.)*
4. From `/account`, choose **Download my wedding**. You get a
- `.trousseau.json` file.
+ `.knotwork.json` file.
*(The document store and the export path work.)*
If step 2 says accounts are not set up, go back to section 3 — it is almost
diff --git a/docs/superpowers/plans/2026-08-20-phase-0-contract-package.md b/docs/superpowers/plans/2026-08-20-phase-0-contract-package.md
index a1bcfa2d..a2b0969b 100644
--- a/docs/superpowers/plans/2026-08-20-phase-0-contract-package.md
+++ b/docs/superpowers/plans/2026-08-20-phase-0-contract-package.md
@@ -1,26 +1,26 @@
-# Trousseau Phase 0 — Contract Package Implementation Plan
+# Knotwork Phase 0 — Contract Package Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-**Goal:** Build and publish `@jfrusher/trousseau` — the schemas, types and file
+**Goal:** Build and publish `@jfrusher/knotwork` — the schemas, types and file
format four wedding apps will share — without touching any of those apps.
**Architecture:** A tiny ESM TypeScript package with one runtime dependency
(zod). It defines an envelope of independently-owned slices, validates them, and
-serialises a `.trousseau.json`. Its single most important property is that it
+serialises a `.knotwork.json`. Its single most important property is that it
never loses data it does not understand: unknown slices and unknown keys within
slices survive every operation byte-for-byte.
**Tech Stack:** TypeScript 5.9, zod 4, vitest 3, plain `tsc` for the build. No
bundler, no framework, no React.
-**Spec:** `docs/superpowers/specs/2026-08-20-trousseau-design.md`
+**Spec:** `docs/superpowers/specs/2026-08-20-knotwork-design.md`
## Global Constraints
- **Phase 0 modifies no existing application.** No file outside
- `c:\Projects\Trousseau` is edited. Fixtures are *copied* in, never moved.
-- **Package name:** `@jfrusher/trousseau`. **Version:** `0.1.0` for the first
+ `c:\Projects\Knotwork` is edited. Fixtures are *copied* in, never moved.
+- **Package name:** `@jfrusher/knotwork`. **Version:** `0.1.0` for the first
publish.
- **Node:** `>=18`. **Module system:** ESM only (`"type": "module"`).
- **Dependencies:** `zod` `^4.4.3` (matching `Tableaux/server/package.json`) is
@@ -73,8 +73,8 @@ bundler, no framework, no React.
| `src/event.ts` | `event` slice schema and type |
| `src/day.ts` | `day` slice schema and type — mirrors Cadence's `ResolvedDay` |
| `src/slices.ts` | `guests`, `seating`, `crew`, `stationery` — open-shaped slices |
-| `src/envelope.ts` | The envelope schema, `SliceName`, `migrate`, `emptyTrousseau` |
-| `src/file.ts` | `serialise` and `parse` for `.trousseau.json` |
+| `src/envelope.ts` | The envelope schema, `SliceName`, `migrate`, `emptyKnotwork` |
+| `src/file.ts` | `serialise` and `parse` for `.knotwork.json` |
| `fixtures/sample-day.day.json` | Copy of Brigade's fixture, for the leniency test |
| `fixtures/minimal.day.json` | A day with every optional field absent |
| `README.md` | Already exists. Task 6 adds usage. |
@@ -103,7 +103,7 @@ screen and so a later phase adding the store client changes one obvious file.
```json
{
- "name": "@jfrusher/trousseau",
+ "name": "@jfrusher/knotwork",
"version": "0.1.0",
"description": "The shared data contract behind Tableaux, Plaque, Cadence and Brigade.",
"license": "MIT",
@@ -577,9 +577,9 @@ The heart of the package. Rules 1 and 2 from the spec become executable here.
**Interfaces:**
- Consumes: `eventSchema` from Task 1, `daySchema` from Task 2.
-- Produces: `trousseauSchema`, `type Trousseau`, `type SliceName`,
- `SLICE_NAMES: readonly SliceName[]`, `TROUSSEAU_KIND`, `TROUSSEAU_VERSION`,
- `emptyTrousseau(): Trousseau`, `migrate(doc: unknown): Trousseau`,
+- Produces: `knotworkSchema`, `type Knotwork`, `type SliceName`,
+ `SLICE_NAMES: readonly SliceName[]`, `KNOTWORK_KIND`, `KNOTWORK_VERSION`,
+ `emptyKnotwork(): Knotwork`, `migrate(doc: unknown): Knotwork`,
`mergeSlice(raw, slice, value)`.
- [ ] **Step 1: Write the open-shaped slices**
@@ -623,26 +623,26 @@ Create `src/envelope.test.ts`:
import { describe, expect, it } from "vitest";
import {
SLICE_NAMES,
- TROUSSEAU_KIND,
- TROUSSEAU_VERSION,
- emptyTrousseau,
+ KNOTWORK_KIND,
+ KNOTWORK_VERSION,
+ emptyKnotwork,
migrate,
- trousseauSchema,
+ knotworkSchema,
} from "./envelope";
-describe("emptyTrousseau", () => {
+describe("emptyKnotwork", () => {
it("is a valid document", () => {
- expect(trousseauSchema.safeParse(emptyTrousseau()).success).toBe(true);
+ expect(knotworkSchema.safeParse(emptyKnotwork()).success).toBe(true);
});
it("has no day until one is published", () => {
- expect(emptyTrousseau().day).toBeNull();
+ expect(emptyKnotwork().day).toBeNull();
});
it("returns a fresh object each call, so callers cannot share state", () => {
- const a = emptyTrousseau();
+ const a = emptyKnotwork();
a.event.coupleNames = "A & B";
- expect(emptyTrousseau().event.coupleNames).toBe("");
+ expect(emptyKnotwork().event.coupleNames).toBe("");
});
});
@@ -666,15 +666,15 @@ describe("SLICE_NAMES", () => {
describe("migrate", () => {
it("accepts an empty object as a new, empty wedding", () => {
const doc = migrate({});
- expect(doc.kind).toBe(TROUSSEAU_KIND);
- expect(doc.version).toBe(TROUSSEAU_VERSION);
+ expect(doc.kind).toBe(KNOTWORK_KIND);
+ expect(doc.version).toBe(KNOTWORK_VERSION);
});
it("accepts a document from the future rather than refusing it", () => {
- expect(() => migrate({ kind: TROUSSEAU_KIND, version: 99 })).not.toThrow();
+ expect(() => migrate({ kind: KNOTWORK_KIND, version: 99 })).not.toThrow();
});
- it("throws on something that is not a trousseau at all", () => {
+ it("throws on something that is not a Knotwork document at all", () => {
expect(() => migrate({ kind: "cadence.day", version: 1 })).toThrow();
});
@@ -699,8 +699,8 @@ import { daySchema } from "./day";
import { eventSchema } from "./event";
import { crewSchema, guestsSchema, seatingSchema, stationerySchema } from "./slices";
-export const TROUSSEAU_KIND = "trousseau";
-export const TROUSSEAU_VERSION = 1;
+export const KNOTWORK_KIND = "knotwork";
+export const KNOTWORK_VERSION = 1;
/**
* The slices an app may publish. `sources` is deliberately absent: it is not
@@ -726,9 +726,9 @@ export type SliceName = (typeof SLICE_NAMES)[number];
* means deleting a slice belonging to an app that has not been written yet.
* That is the single worst thing this package could do.
*/
-export const trousseauSchema = z.looseObject({
- kind: z.literal(TROUSSEAU_KIND).default(TROUSSEAU_KIND),
- version: z.number().default(TROUSSEAU_VERSION),
+export const knotworkSchema = z.looseObject({
+ kind: z.literal(KNOTWORK_KIND).default(KNOTWORK_KIND),
+ version: z.number().default(KNOTWORK_VERSION),
event: eventSchema.default(() => eventSchema.parse({})),
guests: guestsSchema,
seating: seatingSchema,
@@ -740,11 +740,11 @@ export const trousseauSchema = z.looseObject({
sources: z.record(z.string(), z.unknown()).default(() => ({})),
});
-export type Trousseau = z.infer;
+export type Knotwork = z.infer;
/** A new, empty wedding. A fresh object every call. */
-export function emptyTrousseau(): Trousseau {
- return trousseauSchema.parse({});
+export function emptyKnotwork(): Knotwork {
+ return knotworkSchema.parse({});
}
/**
@@ -756,10 +756,10 @@ export function emptyTrousseau(): Trousseau {
*
* Throws rather than returning a result: a caller that cannot read the document
* must not proceed to write over it. Callers that want to tolerate failure use
- * `trousseauSchema.safeParse` and leave the stored bytes alone.
+ * `knotworkSchema.safeParse` and leave the stored bytes alone.
*/
-export function migrate(doc: unknown): Trousseau {
- return trousseauSchema.parse(doc);
+export function migrate(doc: unknown): Knotwork {
+ return knotworkSchema.parse(doc);
}
```
@@ -775,11 +775,11 @@ This is the test the spec says must never be deleted. Create
```ts
import { describe, expect, it } from "vitest";
-import { SLICE_NAMES, mergeSlice, migrate, trousseauSchema } from "./envelope";
+import { SLICE_NAMES, mergeSlice, migrate, knotworkSchema } from "./envelope";
/** A document carrying data from an app that does not exist yet. */
const fromTheFuture = () => ({
- kind: "trousseau",
+ kind: "knotwork",
version: 1,
event: { coupleNames: "Charis & Jacob", hashtag: "#cj2026" },
guests: { "g-1": { name: "Priya" } },
@@ -820,7 +820,7 @@ describe("rule 2: unknown keys inside a known slice survive", () => {
describe("no schema in this package strips unknown keys", () => {
it("round-trips a document with an unknown slice byte-for-byte", () => {
const before = fromTheFuture();
- const after = trousseauSchema.parse(structuredClone(before)) as Record;
+ const after = knotworkSchema.parse(structuredClone(before)) as Record;
for (const [key, value] of Object.entries(before)) {
// Primitives compare whole; objects only need to be a superset, because
// parsing fills defaults the input did not carry.
@@ -856,7 +856,7 @@ Append to `src/envelope.ts`:
/**
* Set one slice on a raw stored document, copying every other key untouched.
*
- * Takes and returns *raw* data, not a parsed `Trousseau`, and that is the whole
+ * Takes and returns *raw* data, not a parsed `Knotwork`, and that is the whole
* point. Parsing produces only what the schemas describe; if a schema is ever
* wrong — a plain `z.object()` slipped in, a slice not yet added here — writing
* the parsed result back would delete real user data. Merging into the raw
@@ -877,8 +877,8 @@ export function mergeSlice(
: {};
return {
...base,
- kind: TROUSSEAU_KIND,
- version: typeof base["version"] === "number" ? base["version"] : TROUSSEAU_VERSION,
+ kind: KNOTWORK_KIND,
+ version: typeof base["version"] === "number" ? base["version"] : KNOTWORK_VERSION,
[slice]: value,
};
}
@@ -945,14 +945,14 @@ Append to `src/index.ts`:
```ts
export {
SLICE_NAMES,
- TROUSSEAU_KIND,
- TROUSSEAU_VERSION,
- emptyTrousseau,
+ KNOTWORK_KIND,
+ KNOTWORK_VERSION,
+ emptyKnotwork,
mergeSlice,
migrate,
- trousseauSchema,
+ knotworkSchema,
type SliceName,
- type Trousseau,
+ type Knotwork,
} from "./envelope";
export {
crewSchema,
@@ -984,7 +984,7 @@ exist yet."
---
-## Task 4: The `.trousseau.json` file format
+## Task 4: The `.knotwork.json` file format
**Files:**
- Create: `src/file.ts`
@@ -992,9 +992,9 @@ exist yet."
- Test: `src/file.test.ts`
**Interfaces:**
-- Consumes: `trousseauSchema`, `migrate`, `emptyTrousseau` from Task 3.
-- Produces: `TROUSSEAU_EXTENSION = ".trousseau.json"`, `serialise(doc): string`,
- `parse(text): Trousseau`, `suggestedFilename(doc): string`.
+- Consumes: `knotworkSchema`, `migrate`, `emptyKnotwork` from Task 3.
+- Produces: `KNOTWORK_EXTENSION = ".knotwork.json"`, `serialise(doc): string`,
+ `parse(text): Knotwork`, `suggestedFilename(doc): string`.
- [ ] **Step 1: Write the failing test**
@@ -1002,28 +1002,28 @@ Create `src/file.test.ts`:
```ts
import { describe, expect, it } from "vitest";
-import { emptyTrousseau } from "./envelope";
-import { TROUSSEAU_EXTENSION, parse, serialise, suggestedFilename } from "./file";
+import { emptyKnotwork } from "./envelope";
+import { KNOTWORK_EXTENSION, parse, serialise, suggestedFilename } from "./file";
describe("serialise", () => {
it("ends with a newline, so the file is well-formed on disk", () => {
- expect(serialise(emptyTrousseau()).endsWith("\n")).toBe(true);
+ expect(serialise(emptyKnotwork()).endsWith("\n")).toBe(true);
});
it("is indented, so a diff of two weddings is readable", () => {
- expect(serialise(emptyTrousseau())).toContain('\n "kind"');
+ expect(serialise(emptyKnotwork())).toContain('\n "kind"');
});
});
describe("parse", () => {
it("round-trips a document", () => {
- const doc = emptyTrousseau();
+ const doc = emptyKnotwork();
doc.event.coupleNames = "Charis & Jacob";
expect(parse(serialise(doc)).event.coupleNames).toBe("Charis & Jacob");
});
it("keeps a slice it does not know about", () => {
- const text = JSON.stringify({ kind: "trousseau", version: 1, florals: { arch: "peonies" } });
+ const text = JSON.stringify({ kind: "knotwork", version: 1, florals: { arch: "peonies" } });
expect(parse(text)).toMatchObject({ florals: { arch: "peonies" } });
});
@@ -1033,19 +1033,19 @@ describe("parse", () => {
it("explains itself when handed a Cadence day", () => {
const day = JSON.stringify({ kind: "cadence.day", version: 1 });
- expect(() => parse(day)).toThrow(/not a Trousseau file/);
+ expect(() => parse(day)).toThrow(/not a Knotwork file/);
});
});
describe("suggestedFilename", () => {
it("uses the couple's names", () => {
- const doc = emptyTrousseau();
+ const doc = emptyKnotwork();
doc.event.coupleNames = "Charis & Jacob";
- expect(suggestedFilename(doc)).toBe(`charis-and-jacob${TROUSSEAU_EXTENSION}`);
+ expect(suggestedFilename(doc)).toBe(`charis-and-jacob${KNOTWORK_EXTENSION}`);
});
it("falls back when there are no names yet", () => {
- expect(suggestedFilename(emptyTrousseau())).toBe(`wedding${TROUSSEAU_EXTENSION}`);
+ expect(suggestedFilename(emptyKnotwork())).toBe(`wedding${KNOTWORK_EXTENSION}`);
});
});
```
@@ -1060,52 +1060,52 @@ Expected: FAIL — `Failed to resolve import "./file"`.
Create `src/file.ts`:
```ts
-import { TROUSSEAU_KIND, migrate, type Trousseau } from "./envelope";
+import { KNOTWORK_KIND, migrate, type Knotwork } from "./envelope";
-export const TROUSSEAU_EXTENSION = ".trousseau.json";
+export const KNOTWORK_EXTENSION = ".knotwork.json";
/** Indented and newline-terminated: these files end up in git and in email. */
-export function serialise(doc: Trousseau): string {
+export function serialise(doc: Knotwork): string {
return JSON.stringify(doc, null, 2) + "\n";
}
/**
- * Read a `.trousseau.json`.
+ * Read a `.knotwork.json`.
*
* Throws with a sentence a person can act on rather than a validation dump.
* This runs on whatever the user dropped on the window, which is often the
* wrong file entirely — most usefully, one of the four apps' own save files.
*/
-export function parse(text: string): Trousseau {
+export function parse(text: string): Knotwork {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
- throw new Error("That file is not valid JSON. Is it a Trousseau file?");
+ throw new Error("That file is not valid JSON. Is it a Knotwork file?");
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
- throw new Error("That file is not a Trousseau file.");
+ throw new Error("That file is not a Knotwork file.");
}
const kind = (raw as Record)["kind"];
- if (kind !== undefined && kind !== TROUSSEAU_KIND) {
+ if (kind !== undefined && kind !== KNOTWORK_KIND) {
throw new Error(
- `That is not a Trousseau file — it says it is a "${String(kind)}".`,
+ `That is not a Knotwork file — it says it is a "${String(kind)}".`,
);
}
return migrate(raw);
}
-/** `charis-and-jacob.trousseau.json`, or a sensible fallback. */
-export function suggestedFilename(doc: Trousseau): string {
+/** `charis-and-jacob.knotwork.json`, or a sensible fallback. */
+export function suggestedFilename(doc: Knotwork): string {
const slug = doc.event.coupleNames
.toLowerCase()
.replace(/&/g, " and ")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
- return `${slug || "wedding"}${TROUSSEAU_EXTENSION}`;
+ return `${slug || "wedding"}${KNOTWORK_EXTENSION}`;
}
```
@@ -1120,7 +1120,7 @@ Append to `src/index.ts`:
```ts
export {
- TROUSSEAU_EXTENSION,
+ KNOTWORK_EXTENSION,
parse,
serialise,
suggestedFilename,
@@ -1134,7 +1134,7 @@ Expected: exit 0.
```bash
git add src/
-git commit -m "Add the .trousseau.json file format
+git commit -m "Add the .knotwork.json file format
parse throws a sentence rather than a validation dump: it runs on
whatever was dropped on the window, and the most likely wrong answer is
@@ -1181,7 +1181,7 @@ Create `verify/tsconfig.json`. These flags are copied verbatim from
"esModuleInterop": true,
"skipLibCheck": false,
"noEmit": true,
- "paths": { "@jfrusher/trousseau": ["../dist/index.d.ts"] }
+ "paths": { "@jfrusher/knotwork": ["../dist/index.d.ts"] }
},
"include": ["consumer.ts"]
}
@@ -1197,7 +1197,7 @@ will, under the app's own compiler settings:
```ts
import {
- emptyTrousseau,
+ emptyKnotwork,
isFromFuture,
mergeSlice,
migrate,
@@ -1207,14 +1207,14 @@ import {
type Day,
type Event,
type SliceName,
- type Trousseau,
-} from "@jfrusher/trousseau";
+ type Knotwork,
+} from "@jfrusher/knotwork";
// A slice name is assignable from a literal.
const slice: SliceName = "day";
// The envelope's fields have the types an app expects.
-const doc: Trousseau = emptyTrousseau();
+const doc: Knotwork = emptyKnotwork();
const event: Event = doc.event;
const names: string = event.coupleNames;
const curfew: number | null = event.curfewMin;
@@ -1232,12 +1232,12 @@ if (day !== null) {
// The file functions compose.
const text: string = serialise(doc);
-const back: Trousseau = parse(text);
+const back: Knotwork = parse(text);
const name: string = suggestedFilename(back);
// mergeSlice takes raw data and a slice name.
const merged: Record = mergeSlice(back, slice, {});
-const remigrated: Trousseau = migrate(merged);
+const remigrated: Knotwork = migrate(merged);
void names;
void curfew;
@@ -1290,7 +1290,7 @@ phased rollout exists to avoid."
**Interfaces:**
- Consumes: everything.
-- Produces: `@jfrusher/trousseau@0.1.0` on npm.
+- Produces: `@jfrusher/knotwork@0.1.0` on npm.
- [ ] **Step 1: Add usage to `README.md`**
@@ -1301,13 +1301,13 @@ Append to the existing `README.md`, above the final "Nothing is built yet" line
## Install
```sh
-npm install @jfrusher/trousseau
+npm install @jfrusher/knotwork
```
## Use
```ts
-import { emptyTrousseau, mergeSlice, migrate, parse, serialise } from "@jfrusher/trousseau";
+import { emptyKnotwork, mergeSlice, migrate, parse, serialise } from "@jfrusher/knotwork";
// Read a stored document. Never throws away what it does not understand.
const doc = migrate(rawFromStorage);
@@ -1341,7 +1341,7 @@ a write.
## Design
-[The full design](docs/superpowers/specs/2026-08-20-trousseau-design.md), including
+[The full design](docs/superpowers/specs/2026-08-20-knotwork-design.md), including
why there is no shared UI kit and no monorepo.
````
@@ -1360,7 +1360,7 @@ If `dist/` is absent, run `npm run build` first and re-check.
- [ ] **Step 4: Confirm the name is available**
-Run: `npm view @jfrusher/trousseau`
+Run: `npm view @jfrusher/knotwork`
Expected: `404 Not Found`, which means the name is free.
If it returns a package, stop and raise it — the spec lists the npm name as an
@@ -1382,12 +1382,12 @@ are private by default, so public access must be explicit:
npm publish --access public
```
-Expected: `+ @jfrusher/trousseau@0.1.0`.
+Expected: `+ @jfrusher/knotwork@0.1.0`.
- [ ] **Step 7: Tag the release**
```bash
-git tag -a v0.1.0 -m "Trousseau 0.1.0 — the contract, no adopters yet"
+git tag -a v0.1.0 -m "Knotwork 0.1.0 — the contract, no adopters yet"
```
- [ ] **Step 8: Confirm a real install works**
@@ -1395,12 +1395,12 @@ git tag -a v0.1.0 -m "Trousseau 0.1.0 — the contract, no adopters yet"
From a scratch directory outside this repo:
```bash
-mkdir -p /tmp/trousseau-check && cd /tmp/trousseau-check
-npm init -y && npm install @jfrusher/trousseau
-node --input-type=module -e "import {emptyTrousseau} from '@jfrusher/trousseau'; console.log(emptyTrousseau().kind)"
+mkdir -p /tmp/knotwork-check && cd /tmp/knotwork-check
+npm init -y && npm install @jfrusher/knotwork
+node --input-type=module -e "import {emptyKnotwork} from '@jfrusher/knotwork'; console.log(emptyKnotwork().kind)"
```
-Expected: prints `trousseau`.
+Expected: prints `knotwork`.
---
@@ -1409,7 +1409,7 @@ Expected: prints `trousseau`.
- [ ] `npm test` passes.
- [ ] `npm run verify` passes — the emitted types compile under Cadence's
compiler settings.
-- [ ] `@jfrusher/trousseau@0.1.0` installs from npm in a clean directory and
+- [ ] `@jfrusher/knotwork@0.1.0` installs from npm in a clean directory and
imports.
- [ ] `git -C /c/Projects/Plaque status`, and the same for Tableaux, cadence and
Brigade, all report **no changes**. Phase 0 touched no application, and
diff --git a/docs/superpowers/plans/2026-09-02-multitenant-storage.md b/docs/superpowers/plans/2026-09-02-multitenant-storage.md
index 8a84bbb9..32d4b544 100644
--- a/docs/superpowers/plans/2026-09-02-multitenant-storage.md
+++ b/docs/superpowers/plans/2026-09-02-multitenant-storage.md
@@ -25,13 +25,13 @@ ported `validate-wedding.mjs` cross-slice check — mirroring
`suite/lib/sync/`'s and `suite/lib/accounts/`'s existing
store/handlers/supabaseStore split exactly. An offline write queue
(`suite/lib/documents/cloudSync.ts`) sits between the existing
-`useTrousseauStore` local-first store and the new API routes, replaying
+`useKnotworkStore` local-first store and the new API routes, replaying
queued writes on reconnect and surfacing any resulting conflict the same way
an online conflict is surfaced — not auto-merged.
**Tech Stack:** Next.js App Router (`suite/`), `@supabase/supabase-js`,
`@supabase/ssr`, `@electric-sql/pglite` (real-Postgres RLS/SQL function
-tests), Vitest, Zod, `idb-keyval` (already used by `useTrousseauStore` for
+tests), Vitest, Zod, `idb-keyval` (already used by `useKnotworkStore` for
local persistence), `zustand`.
**Spec:** `docs/superpowers/specs/2026-09-02-multitenant-storage-design.md`
@@ -40,7 +40,7 @@ local persistence), `zustand`.
**Complete — all 9 tasks, 2026-09-07.** Tasks 1-7 landed 2026-09-03; Tasks 8
and 9 on 2026-09-07, after rebasing the branch onto `main` (it was 41 commits
-behind, and Ensemble had since changed `useTrousseauStore.ts`).
+behind, and Ensemble had since changed `useKnotworkStore.ts`).
Verified on the finished branch, not from memory:
@@ -1467,9 +1467,9 @@ git commit -m "Add GET/PUT /api/documents, resolving the caller's wedding server
**Interfaces:**
- Consumes: nothing from earlier tasks directly (talks to `/api/documents` over `fetch`, and to `idb-keyval` for its own queue) — kept store-agnostic so it is testable in isolation.
-- Produces: `fetchCloudDocument()`, `pushDocument(document, expectedVersion)`, `CloudSyncResult`, `PendingWrite`, `getPendingWrite()`, `clearPendingWrite()`, `startAutoRetry(onSettled)` — consumed by Task 8's integration into `useTrousseauStore`.
+- Produces: `fetchCloudDocument()`, `pushDocument(document, expectedVersion)`, `CloudSyncResult`, `PendingWrite`, `getPendingWrite()`, `clearPendingWrite()`, `startAutoRetry(onSettled)` — consumed by Task 8's integration into `useKnotworkStore`.
-Because Trousseau's document model is one full JSON snapshot per wedding —
+Because Knotwork's document model is one full JSON snapshot per wedding —
not an operation log or per-field diffs — the "queue" the spec describes is
correctly a queue of *at most one* pending write: every local edit already
supersedes whatever was queued before it, the same way the existing
@@ -1657,7 +1657,7 @@ import { del as idbDel, get as idbGet, set as idbSet } from "idb-keyval";
/**
* The offline write queue and cloud transport, kept entirely separate from
- * `useTrousseauStore` so it can be tested with a fake `fetch` and a mocked
+ * `useKnotworkStore` so it can be tested with a fake `fetch` and a mocked
* `idb-keyval`, the same way `persistFailure.test.ts` tests the local store's
* own IndexedDB failure handling.
*
@@ -1667,7 +1667,7 @@ import { del as idbDel, get as idbGet, set as idbSet } from "idb-keyval";
* why that is a property of the data model, not a corner cut.
*/
-const PENDING_WRITE_KEY = "trousseau.cloud.pendingWrite";
+const PENDING_WRITE_KEY = "knotwork.cloud.pendingWrite";
export interface PendingWrite {
document: unknown;
@@ -1780,14 +1780,14 @@ git commit -m "Add the offline write queue and cloud-sync transport"
## Task 8: Wire cloud sync into the local-first store and the UI
**Files:**
-- Modify: `suite/lib/store/useTrousseauStore.ts`
+- Modify: `suite/lib/store/useKnotworkStore.ts`
- Modify: `suite/lib/store/StoreHydrator.tsx`
- Modify: `suite/components/shell/DataManager.tsx`
-- Create: `suite/lib/store/useTrousseauStore.cloudSync.test.ts`
+- Create: `suite/lib/store/useKnotworkStore.cloudSync.test.ts`
**Interfaces:**
- Consumes: `fetchCloudDocument`, `pushDocument`, `replayPendingWrite` (Task 7); `accountsConfigured()` (`@/lib/env`, already shipped).
-- Produces: new `TrousseauState` fields `cloudStatus`, `cloudConflict`, `cloudVersion`; new actions `syncToCloud`, `resolveConflictKeepMine`, `resolveConflictTakeTheirs` — consumed by `DataManager.tsx`.
+- Produces: new `KnotworkState` fields `cloudStatus`, `cloudConflict`, `cloudVersion`; new actions `syncToCloud`, `resolveConflictKeepMine`, `resolveConflictTakeTheirs` — consumed by `DataManager.tsx`.
This is the only task that touches the existing local-first store, and the
change is additive: every new field defaults to a value that makes cloud
@@ -1795,11 +1795,11 @@ sync a no-op unless a caller explicitly starts it, so the no-account
local-only mode (Global Constraint) is unaffected by construction, not by
a runtime check added in every code path.
-- [x] **Step 1: Add cloud-sync state and actions to `useTrousseauStore.ts`**
+- [x] **Step 1: Add cloud-sync state and actions to `useKnotworkStore.ts`**
-Add to `suite/lib/store/useTrousseauStore.ts`. Read the existing file in
+Add to `suite/lib/store/useKnotworkStore.ts`. Read the existing file in
full first (already read as part of this plan's research — the additions
-below slot in next to `TrousseauState`, `replaceDocument`, and
+below slot in next to `KnotworkState`, `replaceDocument`, and
`schedulePersist`).
Add a static import alongside the file's existing top-of-file imports
@@ -1816,7 +1816,7 @@ import {
} from "@/lib/documents/cloudSync";
```
-Add to the `TrousseauState` interface:
+Add to the `KnotworkState` interface:
```ts
/**
@@ -1924,25 +1924,25 @@ plumbing shared by `startCloudSync`/`syncToCloud`/`resolveConflictKeepMine`):
```ts
function applyCloudResult(result: PushResult): void {
if (result.ok) {
- useTrousseauStore.setState({ cloudStatus: "idle", cloudVersion: result.version, cloudConflict: null, cloudError: null });
+ useKnotworkStore.setState({ cloudStatus: "idle", cloudVersion: result.version, cloudConflict: null, cloudError: null });
return;
}
if (result.reason === "conflict") {
- useTrousseauStore.setState({ cloudStatus: "conflict", cloudConflict: { document: result.document, version: result.version } });
+ useKnotworkStore.setState({ cloudStatus: "conflict", cloudConflict: { document: result.document, version: result.version } });
return;
}
if (result.reason === "queued") {
- useTrousseauStore.setState({ cloudStatus: "queued" });
+ useKnotworkStore.setState({ cloudStatus: "queued" });
return;
}
if (result.reason === "invalid") {
- useTrousseauStore.setState({
+ useKnotworkStore.setState({
cloudStatus: "error",
cloudError: `This wedding could not be saved to the cloud: ${result.errors.join("; ")}`,
});
return;
}
- useTrousseauStore.setState({ cloudStatus: "error", cloudError: "The cloud could not be reached." });
+ useKnotworkStore.setState({ cloudStatus: "error", cloudError: "The cloud could not be reached." });
}
```
@@ -1953,8 +1953,8 @@ is unreachable) has actually landed:
```ts
void idbSet(STORAGE_KEY, raw).then(() => {
- useTrousseauStore.setState({ savedAt: new Date().toISOString(), error: null });
- void useTrousseauStore.getState().syncToCloud();
+ useKnotworkStore.setState({ savedAt: new Date().toISOString(), error: null });
+ void useKnotworkStore.getState().syncToCloud();
}, noted);
```
@@ -1967,11 +1967,11 @@ Modify `suite/lib/store/StoreHydrator.tsx`:
import { useEffect } from "react";
import { reconcileLoadedDocument } from "@/lib/seating/normalise";
-import { useTrousseauStore } from "./useTrousseauStore";
+import { useKnotworkStore } from "./useKnotworkStore";
export function StoreHydrator() {
- const hydrate = useTrousseauStore((s) => s.hydrate);
- const startCloudSync = useTrousseauStore((s) => s.startCloudSync);
+ const hydrate = useKnotworkStore((s) => s.hydrate);
+ const startCloudSync = useKnotworkStore((s) => s.startCloudSync);
useEffect(() => {
void hydrate()
.then(reconcileLoadedDocument)
@@ -1991,7 +1991,7 @@ non-browser tests):
```ts
useEffect(() => {
if (typeof window === "undefined") return;
- const onOnline = () => void useTrousseauStore.getState().syncToCloud();
+ const onOnline = () => void useKnotworkStore.getState().syncToCloud();
window.addEventListener("online", onOnline);
return () => window.removeEventListener("online", onOnline);
}, []);
@@ -1999,7 +1999,7 @@ non-browser tests):
- [x] **Step 4: Write a test for the cloud-sync wiring**
-Create `suite/lib/store/useTrousseauStore.cloudSync.test.ts`:
+Create `suite/lib/store/useKnotworkStore.cloudSync.test.ts`:
```ts
import { beforeEach, expect, test, vi } from "vitest";
@@ -2018,14 +2018,14 @@ vi.mock("@/lib/documents/cloudSync", () => ({
replayPendingWrite: async () => null,
}));
-const { useTrousseauStore } = await import("./useTrousseauStore");
-const { emptyTrousseau } = await import("@jfrusher/trousseau");
+const { useKnotworkStore } = await import("./useKnotworkStore");
+const { emptyKnotwork } = await import("@jfrusher/knotwork");
beforeEach(() => {
pushDocumentMock.mockReset();
fetchCloudDocumentMock.mockReset();
- const doc = emptyTrousseau();
- useTrousseauStore.setState({
+ const doc = emptyKnotwork();
+ useKnotworkStore.setState({
status: "ready",
error: null,
raw: doc as unknown as Record,
@@ -2039,35 +2039,35 @@ beforeEach(() => {
test("startCloudSync stays disabled when the cloud reports unavailable", async () => {
fetchCloudDocumentMock.mockResolvedValue({ ok: false, reason: "unavailable" });
- await useTrousseauStore.getState().startCloudSync();
- expect(useTrousseauStore.getState().cloudStatus).toBe("disabled");
+ await useKnotworkStore.getState().startCloudSync();
+ expect(useKnotworkStore.getState().cloudStatus).toBe("disabled");
});
test("startCloudSync adopts the cloud document without creating an undo entry", async () => {
- fetchCloudDocumentMock.mockResolvedValue({ ok: true, document: emptyTrousseau(), version: 4 });
- await useTrousseauStore.getState().startCloudSync();
- const state = useTrousseauStore.getState();
+ fetchCloudDocumentMock.mockResolvedValue({ ok: true, document: emptyKnotwork(), version: 4 });
+ await useKnotworkStore.getState().startCloudSync();
+ const state = useKnotworkStore.getState();
expect(state.cloudStatus).toBe("idle");
expect(state.cloudVersion).toBe(4);
expect(state.past).toEqual([]);
});
test("a rejected write surfaces as a conflict, not an auto-merge", async () => {
- useTrousseauStore.setState({ cloudStatus: "idle", cloudVersion: 1 });
+ useKnotworkStore.setState({ cloudStatus: "idle", cloudVersion: 1 });
pushDocumentMock.mockResolvedValue({ ok: false, reason: "conflict", version: 2, document: { event: { coupleNames: "theirs" } } });
- await useTrousseauStore.getState().syncToCloud();
- const state = useTrousseauStore.getState();
+ await useKnotworkStore.getState().syncToCloud();
+ const state = useKnotworkStore.getState();
expect(state.cloudStatus).toBe("conflict");
expect(state.cloudConflict).toEqual({ document: { event: { coupleNames: "theirs" } }, version: 2 });
});
test("resolveConflictTakeTheirs adopts the cloud document and clears the conflict", () => {
- useTrousseauStore.setState({
+ useKnotworkStore.setState({
cloudStatus: "conflict",
- cloudConflict: { document: emptyTrousseau(), version: 7 },
+ cloudConflict: { document: emptyKnotwork(), version: 7 },
});
- useTrousseauStore.getState().resolveConflictTakeTheirs();
- const state = useTrousseauStore.getState();
+ useKnotworkStore.getState().resolveConflictTakeTheirs();
+ const state = useKnotworkStore.getState();
expect(state.cloudConflict).toBeNull();
expect(state.cloudVersion).toBe(7);
expect(state.cloudStatus).toBe("idle");
@@ -2076,13 +2076,13 @@ test("resolveConflictTakeTheirs adopts the cloud document and clears the conflic
- [x] **Step 5: Run the new test file**
-Run: `npx vitest run --project suite lib/store/useTrousseauStore.cloudSync.test.ts`
+Run: `npx vitest run --project suite lib/store/useKnotworkStore.cloudSync.test.ts`
Expected: PASS (all tests)
- [x] **Step 6: Run the full existing store test suite to confirm nothing regressed**
Run: `npx vitest run --project suite lib/store`
-Expected: PASS (every existing file, including `persistFailure.test.ts`, `history.test.ts`, `migrateKeys.test.ts`, `useTrousseauStore.test.ts`)
+Expected: PASS (every existing file, including `persistFailure.test.ts`, `history.test.ts`, `migrateKeys.test.ts`, `useKnotworkStore.test.ts`)
- [x] **Step 7: Add a minimal "Cloud" section to `DataManager.tsx`**
@@ -2096,14 +2096,14 @@ import { AlertTriangle, CloudOff, Download, FileUp, RefreshCw, Upload, X } from
(Replacing the existing `lucide-react` import line — `RefreshCw` and
`CloudOff` are additions to it, `AlertTriangle` etc. stay.)
-Inside `Body`, alongside the other `useTrousseauStore` selectors:
+Inside `Body`, alongside the other `useKnotworkStore` selectors:
```ts
- const cloudStatus = useTrousseauStore((s) => s.cloudStatus);
- const cloudError = useTrousseauStore((s) => s.cloudError);
- const cloudConflict = useTrousseauStore((s) => s.cloudConflict);
- const resolveConflictTakeTheirs = useTrousseauStore((s) => s.resolveConflictTakeTheirs);
- const resolveConflictKeepMine = useTrousseauStore((s) => s.resolveConflictKeepMine);
+ const cloudStatus = useKnotworkStore((s) => s.cloudStatus);
+ const cloudError = useKnotworkStore((s) => s.cloudError);
+ const cloudConflict = useKnotworkStore((s) => s.cloudConflict);
+ const resolveConflictTakeTheirs = useKnotworkStore((s) => s.resolveConflictTakeTheirs);
+ const resolveConflictKeepMine = useKnotworkStore((s) => s.resolveConflictKeepMine);
```
Add a new `` after the existing `"Sharing"` section (before
@@ -2148,7 +2148,7 @@ Expected: no errors
- [x] **Step 9: Commit**
```bash
-git add suite/lib/store/useTrousseauStore.ts suite/lib/store/StoreHydrator.tsx suite/components/shell/DataManager.tsx suite/lib/store/useTrousseauStore.cloudSync.test.ts
+git add suite/lib/store/useKnotworkStore.ts suite/lib/store/StoreHydrator.tsx suite/components/shell/DataManager.tsx suite/lib/store/useKnotworkStore.cloudSync.test.ts
git commit -m "Wire offline-aware cloud sync into the local-first store and the Data Manager UI"
```
diff --git a/docs/superpowers/plans/2026-09-04-ensemble-group-shots.md b/docs/superpowers/plans/2026-09-04-ensemble-group-shots.md
index 171c452e..9fcbd0b6 100644
--- a/docs/superpowers/plans/2026-09-04-ensemble-group-shots.md
+++ b/docs/superpowers/plans/2026-09-04-ensemble-group-shots.md
@@ -4,7 +4,7 @@
**Goal:** Add Ensemble — a fifth, suite-native tool that builds and prints the family/group photo shot list from the guest list, the room, and a small cast of named roles.
-**Architecture:** A new `shots` slice (contract package + suite reader/writer), pure logic in `suite/lib/ensemble/` (resolve, propose, actions, PDF/CSV renderers), and a UI in `suite/components/ensemble/` that reads/writes the shared `useTrousseauStore` directly — no standalone app, no separate store, unlike the four existing tools.
+**Architecture:** A new `shots` slice (contract package + suite reader/writer), pure logic in `suite/lib/ensemble/` (resolve, propose, actions, PDF/CSV renderers), and a UI in `suite/components/ensemble/` that reads/writes the shared `useKnotworkStore` directly — no standalone app, no separate store, unlike the four existing tools.
**Tech Stack:** TypeScript, Next.js (suite), Zustand, Zod (contract package), pdf-lib (via Brigade's existing PDF kit), `@dnd-kit/core` + `@dnd-kit/sortable` (already a dependency, newly used), Vitest.
@@ -15,11 +15,11 @@
- Cast vocabulary is **bride/groom** (matches `Guest.side`), not partner-neutral.
- Reorder uses **`@dnd-kit/core` + `@dnd-kit/sortable`**, not native HTML5 `draggable`.
- Both suite-wide integrations are in scope: **readiness.ts** rows and the **Wedding Pack** section.
-- Ensemble is **suite-native**: no `suite/apps/ensemble/`, no separate Zustand store, no `sliceBridge`, no `toolGeneration` write-guard. It reads/writes `useTrousseauStore` through `useSuite.ts`, exactly like the suite's own chrome does.
+- Ensemble is **suite-native**: no `suite/apps/ensemble/`, no separate Zustand store, no `sliceBridge`, no `toolGeneration` write-guard. It reads/writes `useKnotworkStore` through `useSuite.ts`, exactly like the suite's own chrome does.
- No component-level (React Testing Library) tests are added anywhere in this plan — the codebase has none (`@testing-library/react` is installed but imported by zero files). UI tasks are verified by hand with the dev server, matching how every other tool's panels are actually verified here.
- `suite/lib/data/file.ts`'s `download(filename, data, type?)` takes the filename **first**. Brigade's own `download` (`apps/brigade/state/projectIO.ts`) takes `(bytes, filename)` — do not copy that arg order into suite-native code.
- Every new pure-logic file (`resolve.ts`, `propose.ts`, `actions.ts`, `shotSheet.ts`, `exports.ts`) gets a real Vitest file. Every new React component does not.
-- Root package (`c:\Projects\Trousseau`) must be rebuilt (`npm run build`) after any change to `src/` before `suite/`'s typecheck or tests will see it — `@jfrusher/trousseau` resolves to `dist/`.
+- Root package (`c:\Projects\Knotwork`) must be rebuilt (`npm run build`) after any change to `src/` before `suite/`'s typecheck or tests will see it — `@jfrusher/knotwork` resolves to `dist/`.
---
@@ -30,7 +30,7 @@
- Modify: `src/envelope.ts`
- Modify: `src/envelope.test.ts`
- Modify: `src/index.ts`
-- Modify: `suite/lib/store/useTrousseauStore.ts` (the `SuiteSlice` import/comment/alias near the top, plus every use of `SuiteSlice` in the file)
+- Modify: `suite/lib/store/useKnotworkStore.ts` (the `SuiteSlice` import/comment/alias near the top, plus every use of `SuiteSlice` in the file)
- Modify: `suite/lib/sync/client.ts:39`
- Modify: `suite/lib/model/timeline.ts:15-18` (comment only)
@@ -64,7 +64,7 @@ describe("SLICE_NAMES", () => {
- [ ] **Step 2: Run the test to verify it fails**
-Run (from `c:\Projects\Trousseau`): `npm test -- envelope.test.ts`
+Run (from `c:\Projects\Knotwork`): `npm test -- envelope.test.ts`
Expected: FAIL — actual array is the six-name list.
- [ ] **Step 3: Add the schemas**
@@ -100,7 +100,7 @@ export const SLICE_NAMES = [
] as const;
```
-And in `trousseauSchema`, after `stationery: stationerySchema,`:
+And in `knotworkSchema`, after `stationery: stationerySchema,`:
```ts
shots: shotsSchema,
@@ -136,20 +136,20 @@ Expected: PASS. Also run `npm test` (full suite) — `src/preservation.test.ts`
- [ ] **Step 7: Rebuild the package**
Run: `npm run build`
-This regenerates `dist/`, which `suite/`'s `@jfrusher/trousseau` dependency resolves to. Nothing in `suite/` will see the new slice names until this runs.
+This regenerates `dist/`, which `suite/`'s `@jfrusher/knotwork` dependency resolves to. Nothing in `suite/` will see the new slice names until this runs.
- [ ] **Step 8: Collapse `SuiteSlice` back to `SliceName`**
-In `suite/lib/store/useTrousseauStore.ts`, find the import block together with the comment and type alias directly below it:
+In `suite/lib/store/useKnotworkStore.ts`, find the import block together with the comment and type alias directly below it:
```ts
import {
- emptyTrousseau,
+ emptyKnotwork,
mergeSlice,
migrate,
type SliceName,
- type Trousseau,
-} from "@jfrusher/trousseau";
+ type Knotwork,
+} from "@jfrusher/knotwork";
/**
* The slices this app writes.
@@ -167,12 +167,12 @@ and replace that whole block (import, comment, and alias together) with just:
```ts
import {
- emptyTrousseau,
+ emptyKnotwork,
mergeSlice,
migrate,
type SliceName,
- type Trousseau,
-} from "@jfrusher/trousseau";
+ type Knotwork,
+} from "@jfrusher/knotwork";
```
Then replace every remaining use of `SuiteSlice` elsewhere in the file with `SliceName` — a search for `SuiteSlice` in this file after the edit above should turn up exactly these:
@@ -196,7 +196,7 @@ with:
const SYNCED: SliceName[] = [...SLICE_NAMES];
```
-And update the import: `import { SLICE_NAMES, type SliceName } from "@jfrusher/trousseau";` (drop the `SuiteSlice` import from `@/lib/store/useTrousseauStore` if it's no longer used elsewhere in the file — check with a search for `SuiteSlice` in this file first). Every other use of `SuiteSlice` as a type annotation in this file (`entries: Array<[SuiteSlice, unknown]>`, `take: Array<[SuiteSlice, unknown]>`, the `as SuiteSlice` casts) becomes `SliceName` / drops the cast.
+And update the import: `import { SLICE_NAMES, type SliceName } from "@jfrusher/knotwork";` (drop the `SuiteSlice` import from `@/lib/store/useKnotworkStore` if it's no longer used elsewhere in the file — check with a search for `SuiteSlice` in this file first). Every other use of `SuiteSlice` as a type annotation in this file (`entries: Array<[SuiteSlice, unknown]>`, `take: Array<[SuiteSlice, unknown]>`, the `as SuiteSlice` casts) becomes `SliceName` / drops the cast.
- [ ] **Step 10: Update the stale comment in `timeline.ts`**
@@ -220,13 +220,13 @@ with:
- [ ] **Step 11: Run every affected test**
-Run (from `c:\Projects\Trousseau\suite`): `npm run typecheck && npm test`
-Expected: PASS. Pay particular attention to `lib/sync/client.test.ts` and `lib/store/useTrousseauStore.test.ts`, which reference `SuiteSlice`/`SYNCED` indirectly.
+Run (from `c:\Projects\Knotwork\suite`): `npm run typecheck && npm test`
+Expected: PASS. Pay particular attention to `lib/sync/client.test.ts` and `lib/store/useKnotworkStore.test.ts`, which reference `SuiteSlice`/`SYNCED` indirectly.
- [ ] **Step 12: Commit**
```bash
-git add src/slices.ts src/envelope.ts src/envelope.test.ts src/index.ts dist suite/lib/store/useTrousseauStore.ts suite/lib/sync/client.ts suite/lib/model/timeline.ts
+git add src/slices.ts src/envelope.ts src/envelope.test.ts src/index.ts dist suite/lib/store/useKnotworkStore.ts suite/lib/sync/client.ts suite/lib/model/timeline.ts
git commit -m "feat: add shots and timeline as real contract slice names"
```
@@ -315,7 +315,7 @@ export interface Shots {
- [ ] **Step 2: Verify it typechecks**
-Run (from `c:\Projects\Trousseau\suite`): `npm run typecheck`
+Run (from `c:\Projects\Knotwork\suite`): `npm run typecheck`
Expected: PASS (these are pure additive type/const exports; nothing consumes them yet).
- [ ] **Step 3: Commit**
@@ -335,7 +335,7 @@ git commit -m "feat: add the group shots types"
**Interfaces:**
- Consumes: `Cast`, `CastRole`, `CAST_ROLES`, `Shot`, `ShotMember`, `ShotSection`, `Shots` (Task 2).
-- Produces: `emptyCast(): Cast`, `emptyShots(): Shots`, `readShots(doc: Trousseau): Shots` — cached per document, same contract as every other reader in this file.
+- Produces: `emptyCast(): Cast`, `emptyShots(): Shots`, `readShots(doc: Knotwork): Shots` — cached per document, same contract as every other reader in this file.
- [ ] **Step 1: Write the failing test**
@@ -347,7 +347,7 @@ In `suite/lib/model/selectors.test.ts`, add `shots: { cast: {}, sections: [{ id:
- [ ] **Step 2: Run the test to verify it fails**
-Run (from `c:\Projects\Trousseau\suite`): `npm test -- selectors.test.ts`
+Run (from `c:\Projects\Knotwork\suite`): `npm test -- selectors.test.ts`
Expected: FAIL — `readShots` is not exported from `./slices`.
- [ ] **Step 3: Implement the reader**
@@ -416,7 +416,7 @@ export function emptyShots(): Shots {
return { cast: emptyCast(), sections: [] };
}
-export function readShots(doc: Trousseau): Shots {
+export function readShots(doc: Knotwork): Shots {
return cached(doc, "shots", () => {
const raw: Record = isRecord((doc as Record)["shots"])
? ((doc as Record)["shots"] as Record)
@@ -457,7 +457,7 @@ git commit -m "feat: read the shots slice"
In `suite/lib/model/useSuite.ts`, add `readShots` to the `import { ... } from "./slices"` block and `Shots` to the `import type { ... } from "./types"` block, then add near `useCrew`:
```ts
-export const useShots = (): Shots => useTrousseauStore((s) => readShots(s.doc));
+export const useShots = (): Shots => useKnotworkStore((s) => readShots(s.doc));
```
- [ ] **Step 2: Add the writer**
@@ -482,7 +482,7 @@ Add `setShots` to the final `return { setEvent, setGuests, setSeating, setTimeli
- [ ] **Step 3: Verify it typechecks**
-Run (from `c:\Projects\Trousseau\suite`): `npm run typecheck`
+Run (from `c:\Projects\Knotwork\suite`): `npm run typecheck`
Expected: PASS. `setSlice("shots", ...)` now type-checks against `SliceName` because Task 1 added `"shots"` to it.
- [ ] **Step 4: Commit**
@@ -620,7 +620,7 @@ describe("cast", () => {
- [ ] **Step 2: Run the tests to verify they fail**
-Run (from `c:\Projects\Trousseau\suite`): `npm test -- lib/ensemble/actions.test.ts`
+Run (from `c:\Projects\Knotwork\suite`): `npm test -- lib/ensemble/actions.test.ts`
Expected: FAIL — `./actions` does not exist.
- [ ] **Step 3: Implement**
@@ -922,7 +922,7 @@ describe("resolveShot: label", () => {
- [ ] **Step 2: Run the tests to verify they fail**
-Run (from `c:\Projects\Trousseau\suite`): `npm test -- lib/ensemble/resolve.test.ts`
+Run (from `c:\Projects\Knotwork\suite`): `npm test -- lib/ensemble/resolve.test.ts`
Expected: FAIL — `./resolve` does not exist.
- [ ] **Step 3: Implement**
@@ -1186,7 +1186,7 @@ describe("propose: generate", () => {
- [ ] **Step 2: Run the tests to verify they fail**
-Run (from `c:\Projects\Trousseau\suite`): `npm test -- lib/ensemble/propose.test.ts`
+Run (from `c:\Projects\Knotwork\suite`): `npm test -- lib/ensemble/propose.test.ts`
Expected: FAIL — `./propose` does not exist.
- [ ] **Step 3: Implement**
@@ -1372,7 +1372,7 @@ const TINT: Record = {
- [ ] **Step 2: Run the test to verify it fails**
-Run (from `c:\Projects\Trousseau\suite`): `npm test -- contrast.test.ts`
+Run (from `c:\Projects\Knotwork\suite`): `npm test -- contrast.test.ts`
Expected: FAIL — `--accent` is not defined for `.ensemble-tokens`.
- [ ] **Step 3: Add the token block**
@@ -1509,7 +1509,7 @@ export function GuestChip({ name, onRemove }: { name: string; onRemove: () => vo
- [ ] **Step 2: Verify it typechecks**
-Run (from `c:\Projects\Trousseau\suite`): `npm run typecheck`
+Run (from `c:\Projects\Knotwork\suite`): `npm run typecheck`
Expected: PASS.
- [ ] **Step 3: Verify by hand**
@@ -1722,7 +1722,7 @@ function ShotRow({
- [ ] **Step 2: Verify it typechecks**
-Run (from `c:\Projects\Trousseau\suite`): `npm run typecheck`
+Run (from `c:\Projects\Knotwork\suite`): `npm run typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
@@ -1916,7 +1916,7 @@ The three `SelectField`s that add-by-choosing (family/group/role) always render
- [ ] **Step 2: Verify it typechecks**
-Run (from `c:\Projects\Trousseau\suite`): `npm run typecheck`
+Run (from `c:\Projects\Knotwork\suite`): `npm run typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
@@ -2013,7 +2013,7 @@ export function CastPanel({
- [ ] **Step 2: Verify it typechecks**
-Run (from `c:\Projects\Trousseau\suite`): `npm run typecheck`
+Run (from `c:\Projects\Knotwork\suite`): `npm run typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
@@ -2152,7 +2152,7 @@ describe("renderShotSheet", () => {
- [ ] **Step 2: Run the tests to verify they fail**
-Run (from `c:\Projects\Trousseau\suite`): `npm test -- lib/ensemble/render/pdf/shotSheet.test.ts`
+Run (from `c:\Projects\Knotwork\suite`): `npm test -- lib/ensemble/render/pdf/shotSheet.test.ts`
Expected: FAIL — `./shotSheet` does not exist.
- [ ] **Step 3: Implement**
@@ -2455,7 +2455,7 @@ describe("shotListCsv", () => {
- [ ] **Step 2: Run the test to verify it fails**
-Run (from `c:\Projects\Trousseau\suite`): `npm test -- lib/ensemble/exports.test.ts`
+Run (from `c:\Projects\Knotwork\suite`): `npm test -- lib/ensemble/exports.test.ts`
Expected: FAIL — `./exports` does not exist.
- [ ] **Step 3: Implement**
@@ -2570,7 +2570,7 @@ export function PrintPanel({
fontSource: browserFontSource(),
pageSize,
coupleNames,
- generatedOn: `Made with Trousseau, ${new Date().toLocaleDateString()}`,
+ generatedOn: `Made with Knotwork, ${new Date().toLocaleDateString()}`,
});
download(`${slug()}-group-shots.pdf`, new Blob([bytes as BlobPart], { type: "application/pdf" }));
} catch (cause) {
@@ -2632,7 +2632,7 @@ export function PrintPanel({
- [ ] **Step 2: Verify it typechecks**
-Run (from `c:\Projects\Trousseau\suite`): `npm run typecheck`
+Run (from `c:\Projects\Knotwork\suite`): `npm run typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
@@ -2771,7 +2771,7 @@ export default function GroupShotsPage() {
- [ ] **Step 3: Verify it typechecks**
-Run (from `c:\Projects\Trousseau\suite`): `npm run typecheck`
+Run (from `c:\Projects\Knotwork\suite`): `npm run typecheck`
Expected: PASS.
- [ ] **Step 4: Verify by hand**
@@ -2835,7 +2835,7 @@ describe("group shots", () => {
- [ ] **Step 2: Run the tests to verify they fail**
-Run (from `c:\Projects\Trousseau\suite`): `npm test -- readiness.test.ts`
+Run (from `c:\Projects\Knotwork\suite`): `npm test -- readiness.test.ts`
Expected: FAIL — no `"shots-dangling"` row exists yet.
- [ ] **Step 3: Implement**
@@ -2917,7 +2917,7 @@ Add after `jobList()`:
```ts
async function shotSheet(): Promise {
- const { doc } = useTrousseauStore.getState();
+ const { doc } = useKnotworkStore.getState();
const shots = readShots(doc);
const total = shots.sections.reduce((sum, section) => sum + section.shots.length, 0);
if (total === 0) return null;
@@ -2930,7 +2930,7 @@ async function shotSheet(): Promise {
return renderShotSheet(shots.sections, readGuests(doc), readSeating(doc), shots.cast, {
fontSource: browserFontSource(),
coupleNames: doc.event.coupleNames,
- generatedOn: `Made with Trousseau, ${new Date().toLocaleDateString()}`,
+ generatedOn: `Made with Knotwork, ${new Date().toLocaleDateString()}`,
});
}
```
@@ -2978,7 +2978,7 @@ to:
- [ ] **Step 5: Verify it typechecks**
-Run (from `c:\Projects\Trousseau\suite`): `npm run typecheck`
+Run (from `c:\Projects\Knotwork\suite`): `npm run typecheck`
Expected: PASS.
- [ ] **Step 6: Verify by hand**
@@ -3063,7 +3063,7 @@ describe("shots slice", () => {
- [ ] **Step 2: Run the tests to verify they fail**
-Run (from `c:\Projects\Trousseau`): `npm test -- validate-wedding.test.mjs`
+Run (from `c:\Projects\Knotwork`): `npm test -- validate-wedding.test.mjs`
Expected: FAIL — no shots checks exist yet.
- [ ] **Step 3: Implement**
@@ -3174,7 +3174,7 @@ In `suite/lib/model/roundTrip.test.ts`:
Since Tasks 3 and 5 are already done by this point in the plan, this task is a pure extension of existing coverage rather than new behavior — run it once after editing:
-Run (from `c:\Projects\Trousseau\suite`): `npm test -- roundTrip.test.ts`
+Run (from `c:\Projects\Knotwork\suite`): `npm test -- roundTrip.test.ts`
Expected: PASS immediately (the underlying `readShots`/`addSection`/`addShot`/`patchShot` already exist and work; this task only adds assertions that exercise them together for the first time).
- [ ] **Step 3: Commit**
@@ -3189,7 +3189,7 @@ git commit -m "test: cover the shots slice in the suite-wide round-trip test"
## Task 21: Docs — root README
**Files:**
-- Modify: `README.md` (repo root, `c:\Projects\Trousseau\README.md`)
+- Modify: `README.md` (repo root, `c:\Projects\Knotwork\README.md`)
**Interfaces:** none — copy only.
diff --git a/docs/superpowers/plans/2026-09-07-guided-tour.md b/docs/superpowers/plans/2026-09-07-guided-tour.md
index 722e6a41..b042708d 100644
--- a/docs/superpowers/plans/2026-09-07-guided-tour.md
+++ b/docs/superpowers/plans/2026-09-07-guided-tour.md
@@ -4,7 +4,7 @@
**Goal:** Give a first-time user an in-app guided tour of the five tools and the
shell, running against a loadable example wedding, so the thing that makes
-Trousseau worth using — that the tools share one document — is visible before
+Knotwork worth using — that the tools share one document — is visible before
they have done enough work to discover it themselves.
**Architecture:** Three separated pieces. `lib/tour/steps.ts` is pure step data
@@ -65,7 +65,7 @@ around. Scrolling now happens once when a step opens; the listeners only
re-measure.
**One thing the plan did not anticipate.** `suite/fixtures/.gitignore` denies
-`*.trousseau.json` and allows specific files by name, to stop a real export
+`*.knotwork.json` and allows specific files by name, to stop a real export
reaching a public repo. The fixture was added to that allowlist rather than
forced past it, so the guard still catches anything else dropped in there.
@@ -119,8 +119,8 @@ Checked in the repo, not assumed.
`confirm("Start a new day? The current one will be replaced.")`.
- `suite/fixtures/guests-150.csv` is synthetic — invented names, with `Table`,
`Dietary` and `Entree` columns. Safe to base the example wedding on.
-- The real wedding files (`wedding.trousseau.json`,
- `data/wedding.trousseau.json`) are gitignored and **must never be read by
+- The real wedding files (`wedding.knotwork.json`,
+ `data/wedding.knotwork.json`) are gitignored and **must never be read by
anything in this plan**.
## File Structure
@@ -132,8 +132,8 @@ Checked in the repo, not assumed.
| `suite/lib/tour/useTour.tsx` | **Create.** The context provider and its hook. |
| `suite/lib/tour/exampleWedding.ts` | **Create.** Loading the fixture, and the backup prompt. |
| `suite/lib/tour/exampleWedding.test.ts` | **Create.** The guard: never replaces work without a yes. |
-| `suite/public/fixtures/example-wedding.trousseau.json` | **Create.** The same fixture, where the browser can fetch it. |
-| `suite/fixtures/example-wedding.trousseau.json` | **Create.** The example wedding, exported from the real app. |
+| `suite/public/fixtures/example-wedding.knotwork.json` | **Create.** The same fixture, where the browser can fetch it. |
+| `suite/fixtures/example-wedding.knotwork.json` | **Create.** The example wedding, exported from the real app. |
| `suite/components/tour/TourOverlay.tsx` | **Create.** Highlight ring and card. |
| `suite/components/shell/TourButtons.tsx` | **Create.** The two entry points. |
| `suite/app/(app)/layout.tsx` | **Modify.** Wrap in the provider, mount the overlay. |
@@ -147,7 +147,7 @@ Checked in the repo, not assumed.
## Task 1: The example wedding fixture
**Files:**
-- Create: `suite/fixtures/example-wedding.trousseau.json`
+- Create: `suite/fixtures/example-wedding.knotwork.json`
**Interfaces:**
- Produces: the fixture file, read by `lib/tour/exampleWedding.ts` (Task 5).
@@ -179,7 +179,7 @@ Open and, in this order:
- [x] **Step 3: Export it**
Press **Data → Export backup**. Move the downloaded file to
-`suite/fixtures/example-wedding.trousseau.json`.
+`suite/fixtures/example-wedding.knotwork.json`.
- [x] **Step 4: Check it contains nothing real**
@@ -191,7 +191,7 @@ Check the two things that actually distinguish the example from real data — th
couple and the venue you just typed:
```bash
-node -e "const d=require('./suite/fixtures/example-wedding.trousseau.json');const e=d.event||{};if(e.coupleNames!=='Alex & Sam'||e.venueName!=='The Old Granary'){console.error('NOT the example wedding:',e);process.exit(1)}console.log('ok — example wedding')"
+node -e "const d=require('./suite/fixtures/example-wedding.knotwork.json');const e=d.event||{};if(e.coupleNames!=='Alex & Sam'||e.venueName!=='The Old Granary'){console.error('NOT the example wedding:',e);process.exit(1)}console.log('ok — example wedding')"
```
Expected: `ok — example wedding`. A non-zero exit means the browser exported a
different document than the one built in Step 2 — most likely a stale profile
@@ -200,14 +200,14 @@ holding other work. Do not commit it.
Then confirm it is a complete document:
```bash
-node -e "const d=require('./suite/fixtures/example-wedding.trousseau.json');console.log(Object.keys(d),'guests',Object.keys(d.guests||{}).length,'blocks',(d.day&&d.day.blocks||[]).length)"
+node -e "const d=require('./suite/fixtures/example-wedding.knotwork.json');console.log(Object.keys(d),'guests',Object.keys(d.guests||{}).length,'blocks',(d.day&&d.day.blocks||[]).length)"
```
Expected: a `guests` count near 100 and a non-zero block count.
- [x] **Step 5: Commit**
```bash
-git add suite/fixtures/example-wedding.trousseau.json
+git add suite/fixtures/example-wedding.knotwork.json
git commit -m "Add the example wedding, exported from the running app"
```
@@ -375,7 +375,7 @@ export const CHAPTERS: readonly TourChapter[] = [
{
anchor: "shell.countdown",
title: "Your wedding",
- body: "Who is getting married, where, and when. Everything else in Trousseau hangs off these three facts, so it is worth filling them in first.",
+ body: "Who is getting married, where, and when. Everything else in Knotwork hangs off these three facts, so it is worth filling them in first.",
route: "/",
},
{
@@ -429,7 +429,7 @@ export const CHAPTERS: readonly TourChapter[] = [
{
anchor: "seating.import",
title: "Bring a list you already have",
- body: "Import a CSV from Joy, Zola, The Knot or your own spreadsheet. Trousseau guesses the columns and asks about anything it cannot. Re-importing updates people rather than duplicating them.",
+ body: "Import a CSV from Joy, Zola, The Knot or your own spreadsheet. Knotwork guesses the columns and asks about anything it cannot. Re-importing updates people rather than duplicating them.",
route: "/seating",
},
{
@@ -507,7 +507,7 @@ export const CHAPTERS: readonly TourChapter[] = [
{
anchor: "placecards.problems",
title: "Before you print",
- body: "Missing fonts, images that have not loaded, guests with no table. Trousseau refuses to print a broken card, which is cheaper than finding out after the good card stock has gone through.",
+ body: "Missing fonts, images that have not loaded, guests with no table. Knotwork refuses to print a broken card, which is cheaper than finding out after the good card stock has gone through.",
route: "/place-cards",
},
{
@@ -567,7 +567,7 @@ export const CHAPTERS: readonly TourChapter[] = [
{
anchor: "groupshots.inspector",
title: "Who is in this one",
- body: "Add people from your guest list or from the cast. Trousseau can also propose the usual shots from families you have already set up in Seating.",
+ body: "Add people from your guest list or from the cast. Knotwork can also propose the usual shots from families you have already set up in Seating.",
route: "/group-shots",
},
{
@@ -645,7 +645,7 @@ import { CHAPTERS, type ChapterId, type TourStep } from "./steps";
* Timeline keeps its place without any extra machinery.
*/
-const SEEN_KEY = "trousseau.tour.seen";
+const SEEN_KEY = "knotwork.tour.seen";
/** Storage can throw outright in a private window, so every touch is wrapped. */
function readSeen(): boolean {
@@ -867,7 +867,7 @@ git commit -m "Anchor the tour to real controls with data-tour attributes"
- Create: `suite/lib/tour/exampleWedding.ts`
**Interfaces:**
-- Consumes: `useTrousseauStore` from `@/lib/store/useTrousseauStore`;
+- Consumes: `useKnotworkStore` from `@/lib/store/useKnotworkStore`;
the fixture from Task 1.
- Produces: `isWeddingEmpty(): boolean` and
`loadExampleWedding(): Promise<"loaded" | "cancelled">`. Consumed by Task 7.
@@ -879,7 +879,7 @@ Create `suite/lib/tour/exampleWedding.ts`:
```ts
"use client";
-import { useTrousseauStore } from "@/lib/store/useTrousseauStore";
+import { useKnotworkStore } from "@/lib/store/useKnotworkStore";
/**
* The example wedding, and the guard in front of it.
@@ -891,13 +891,13 @@ import { useTrousseauStore } from "@/lib/store/useTrousseauStore";
/** Nothing worth losing: no guests and no blocks. */
export function isWeddingEmpty(): boolean {
- const { doc } = useTrousseauStore.getState();
+ const { doc } = useKnotworkStore.getState();
return Object.keys(doc.guests).length === 0 && (doc.day?.blocks.length ?? 0) === 0;
}
export async function loadExampleWedding(): Promise<"loaded" | "cancelled"> {
if (!isWeddingEmpty()) {
- const { doc } = useTrousseauStore.getState();
+ const { doc } = useKnotworkStore.getState();
const guests = Object.keys(doc.guests).length;
const blocks = doc.day?.blocks.length ?? 0;
const confirmed = window.confirm(
@@ -908,14 +908,14 @@ export async function loadExampleWedding(): Promise<"loaded" | "cancelled"> {
if (!confirmed) return "cancelled";
}
- const response = await fetch("/fixtures/example-wedding.trousseau.json");
+ const response = await fetch("/fixtures/example-wedding.knotwork.json");
if (!response.ok) throw new Error("The example wedding could not be loaded.");
const document: unknown = await response.json();
// `silent` keeps it out of the undo stack: the user did not make this change
// by editing, and offering to undo it would offer to restore what they were
// just warned they were replacing.
- useTrousseauStore.getState().replaceDocument(document, { silent: true });
+ useKnotworkStore.getState().replaceDocument(document, { silent: true });
return "loaded";
}
```
@@ -927,7 +927,7 @@ Copy it:
```bash
mkdir -p suite/public/fixtures
-cp suite/fixtures/example-wedding.trousseau.json suite/public/fixtures/
+cp suite/fixtures/example-wedding.knotwork.json suite/public/fixtures/
```
Both copies are committed. `suite/fixtures/` is where the tests read it from,
@@ -940,9 +940,9 @@ Append to `suite/lib/tour/steps.test.ts`:
```ts
describe("the example wedding", () => {
it("is a document the app can actually read", async () => {
- const { migrate } = await import("@jfrusher/trousseau");
+ const { migrate } = await import("@jfrusher/knotwork");
const raw = JSON.parse(
- readFileSync("fixtures/example-wedding.trousseau.json", "utf8"),
+ readFileSync("fixtures/example-wedding.knotwork.json", "utf8"),
) as unknown;
const doc = migrate(raw);
expect(Object.keys(doc.guests).length).toBeGreaterThan(20);
@@ -950,8 +950,8 @@ describe("the example wedding", () => {
});
it("is served to the browser as well as read by tests", () => {
- const served = readFileSync("public/fixtures/example-wedding.trousseau.json", "utf8");
- const source = readFileSync("fixtures/example-wedding.trousseau.json", "utf8");
+ const served = readFileSync("public/fixtures/example-wedding.knotwork.json", "utf8");
+ const source = readFileSync("fixtures/example-wedding.knotwork.json", "utf8");
expect(served).toBe(source);
});
});
@@ -970,15 +970,15 @@ vi.mock("idb-keyval", () => ({
del: async () => undefined,
}));
-const { useTrousseauStore } = await import("@/lib/store/useTrousseauStore");
+const { useKnotworkStore } = await import("@/lib/store/useKnotworkStore");
const { isWeddingEmpty, loadExampleWedding } = await import("./exampleWedding");
-const { emptyTrousseau } = await import("@jfrusher/trousseau");
+const { emptyKnotwork } = await import("@jfrusher/knotwork");
const example = { event: { coupleNames: "Alex & Sam" }, guests: { g1: { id: "g1" } } };
beforeEach(() => {
- const doc = emptyTrousseau();
- useTrousseauStore.setState({
+ const doc = emptyKnotwork();
+ useKnotworkStore.setState({
status: "ready",
error: null,
raw: doc as unknown as Record,
@@ -1007,26 +1007,26 @@ test("an untouched wedding is empty, and loads without asking anything", async (
});
test("a wedding with guests in it is never replaced without a yes", async () => {
- const doc = { ...emptyTrousseau(), guests: { a: { id: "a" } } };
- useTrousseauStore.setState({ raw: doc as unknown as Record, doc });
+ const doc = { ...emptyKnotwork(), guests: { a: { id: "a" } } };
+ useKnotworkStore.setState({ raw: doc as unknown as Record, doc });
vi.stubGlobal("confirm", vi.fn(() => false));
expect(isWeddingEmpty()).toBe(false);
await expect(loadExampleWedding()).resolves.toBe("cancelled");
// The refusal has to leave the document exactly as it was.
- expect(Object.keys(useTrousseauStore.getState().doc.guests)).toEqual(["a"]);
+ expect(Object.keys(useKnotworkStore.getState().doc.guests)).toEqual(["a"]);
});
test("saying yes replaces it, without becoming an undo step", async () => {
- const doc = { ...emptyTrousseau(), guests: { a: { id: "a" } } };
- useTrousseauStore.setState({ raw: doc as unknown as Record, doc, past: [] });
+ const doc = { ...emptyKnotwork(), guests: { a: { id: "a" } } };
+ useKnotworkStore.setState({ raw: doc as unknown as Record, doc, past: [] });
vi.stubGlobal("confirm", vi.fn(() => true));
await expect(loadExampleWedding()).resolves.toBe("loaded");
- expect(useTrousseauStore.getState().doc.event.coupleNames).toBe("Alex & Sam");
+ expect(useKnotworkStore.getState().doc.event.coupleNames).toBe("Alex & Sam");
// Silent: offering to undo would offer to restore what the user was just
// warned they were replacing.
- expect(useTrousseauStore.getState().past).toEqual([]);
+ expect(useKnotworkStore.getState().past).toEqual([]);
});
```
diff --git a/docs/superpowers/plans/2026-09-07-licensing-and-self-hosting.md b/docs/superpowers/plans/2026-09-07-licensing-and-self-hosting.md
index 02cea3e9..4f228ad7 100644
--- a/docs/superpowers/plans/2026-09-07-licensing-and-self-hosting.md
+++ b/docs/superpowers/plans/2026-09-07-licensing-and-self-hosting.md
@@ -2,8 +2,8 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-**Goal:** Put the AGPL over the Trousseau application while leaving the
-published `@jfrusher/trousseau` contract package permissive, and write a
+**Goal:** Put the AGPL over the Knotwork application while leaving the
+published `@jfrusher/knotwork` contract package permissive, and write a
self-hosting runbook that actually works on a fresh clone.
**Architecture:** No code changes. The repo root is simultaneously "the whole
@@ -38,7 +38,7 @@ cloned to a scratch directory with no `node_modules`, then every command in the
document was run. That is what turned up the failure it now documents — `npm
install` at the root does not create `dist/`, and `suite`'s own install
*succeeds* without it, so nothing goes wrong until `next build` emits four
-copies of `Module not found: Can't resolve '@jfrusher/trousseau'` with no
+copies of `Module not found: Can't resolve '@jfrusher/knotwork'` with no
mention of build order. The runbook quotes that error verbatim.
One small thing this plan did not anticipate: inserting the two account
@@ -69,7 +69,7 @@ spec literally says.
1. **The contract package stays MIT; only the application goes AGPL.** The spec
says to update the `license` field in *every* `package.json`, root included.
- But the root package is `@jfrusher/trousseau`, published to npm, and the
+ But the root package is `@jfrusher/knotwork`, published to npm, and the
founding design says a fifth app "joins by depending on the package". AGPL is
viral for anyone importing it, so AGPL there would mean nobody outside this
repo can adopt the contract — fighting the ecosystem goal for no gain. The
@@ -172,25 +172,25 @@ guessing.
Create `LICENSE`:
```
-Trousseau is released under two licences, because this repository holds two
+Knotwork is released under two licences, because this repository holds two
different things.
- The contract package — @jfrusher/trousseau
+ The contract package — @jfrusher/knotwork
------------------------------------------
MIT. See LICENSE-MIT.
This is the published npm package: the schemas and the file format that
describe a wedding. It is deliberately permissive so that a tool nobody has
written yet can depend on it, which is the entire point of the format
- existing. If you installed @jfrusher/trousseau from npm, this is the licence
+ existing. If you installed @jfrusher/knotwork from npm, this is the licence
that applies to you, and you can stop reading here.
The application — everything in suite/
--------------------------------------
GNU Affero General Public License v3.0 or later. See LICENSE-AGPL.
- This is Trousseau itself: the five tools, the shell, the sync and account
- layers. The AGPL is chosen deliberately. Trousseau is free and always will
+ This is Knotwork itself: the five tools, the shell, the sync and account
+ layers. The AGPL is chosen deliberately. Knotwork is free and always will
be, and the AGPL is what stops someone running a paid, closed fork of the
hosted service against the intent of everyone who worked on the free one.
Run it yourself, change it, host it for your friends — but if you host a
@@ -333,7 +333,7 @@ npx next build
```
Confirm that skipping the root `npm run build` fails, and note the error. This
-is the trap: `suite/package.json` depends on `"@jfrusher/trousseau": "file:.."`,
+is the trap: `suite/package.json` depends on `"@jfrusher/knotwork": "file:.."`,
which resolves to the root's `dist/`, and `suite`'s own `dev` script does not
build it.
@@ -342,9 +342,9 @@ build it.
Create `docs/SELF-HOSTING.md`:
````markdown
-# Running your own Trousseau
+# Running your own Knotwork
-Trousseau is free software and this is a genuinely supported way to use it, not
+Knotwork is free software and this is a genuinely supported way to use it, not
a theoretical one. Everything below was run on a fresh clone before it was
written down.
@@ -360,7 +360,7 @@ Two pieces, licensed differently (see `LICENSE`):
- The **application** in `suite/` — a Next.js app. AGPL-3.0-or-later. If you
host a modified version for other people, they are entitled to your source.
- The **contract package** at the repo root, published as
- `@jfrusher/trousseau`. MIT. It is the schemas and the file format.
+ `@jfrusher/knotwork`. MIT. It is the schemas and the file format.
## Requirements
@@ -376,8 +376,8 @@ Two pieces, licensed differently (see `LICENSE`):
The order matters, and getting it wrong is the most common way to fail:
```sh
-git clone
-cd Trousseau
+git clone Knotwork
+cd Knotwork
npm install # the contract package's dependencies
npm run build # builds dist/ — do not skip this
@@ -387,7 +387,7 @@ npm install
```
**Why `npm run build` first.** `suite/package.json` depends on
-`"@jfrusher/trousseau": "file:.."`, which resolves to the root's `dist/`
+`"@jfrusher/knotwork": "file:.."`, which resolves to the root's `dist/`
directory. That directory does not exist in a fresh clone, and `suite`'s `dev`
script does not create it — only `suite`'s `build` script does. Skip the root
build and `npm run dev` fails to resolve the contract package with an error
@@ -490,7 +490,7 @@ Then, in the browser:
2. Go to `/account` and sign in with a magic link. *(Accounts and email work.)*
3. Add a guest, reload. It is still there. *(Cloud sync works.)*
4. From `/account`, click **Download my wedding**. You get a
- `.trousseau.json` file. *(The document store and export work.)*
+ `.knotwork.json` file. *(The document store and export work.)*
If step 2 says accounts are not set up, revisit section 3 — it is almost always
`NEXT_PUBLIC_SUPABASE_URL` or `NEXT_PUBLIC_SUPABASE_ANON_KEY` missing.
@@ -550,12 +550,12 @@ Replace the existing section at the end of `README.md`:
Two licences, because this repository holds two things.
The **application** — everything in `suite/` — is
-[AGPL-3.0-or-later](LICENSE-AGPL). Trousseau is free and always will be. The
+[AGPL-3.0-or-later](LICENSE-AGPL). Knotwork is free and always will be. The
AGPL is what keeps it that way: run it yourself, change it, host it for
friends, but host a modified version for other people and they get the source
too.
-The **contract package**, `@jfrusher/trousseau`, is [MIT](LICENSE-MIT). It is
+The **contract package**, `@jfrusher/knotwork`, is [MIT](LICENSE-MIT). It is
the schemas and the file format, kept permissive on purpose so a tool nobody
has written yet can depend on it.
diff --git a/docs/superpowers/plans/2026-09-07-multitenant-mechanics.md b/docs/superpowers/plans/2026-09-07-multitenant-mechanics.md
index e723bfcb..2c674c53 100644
--- a/docs/superpowers/plans/2026-09-07-multitenant-mechanics.md
+++ b/docs/superpowers/plans/2026-09-07-multitenant-mechanics.md
@@ -17,7 +17,7 @@ the limit, and the `Content-Disposition` header. No new tables, no new
migration, no new dependency.
**Tech Stack:** Next.js App Router (`suite/`), TypeScript, Vitest,
-`@jfrusher/trousseau` (for `migrate`, `suggestedFilename`, `TROUSSEAU_EXTENSION`).
+`@jfrusher/knotwork` (for `migrate`, `suggestedFilename`, `KNOTWORK_EXTENSION`).
**Spec:** [docs/superpowers/specs/2026-09-02-multitenant-mechanics-design.md](../specs/2026-09-02-multitenant-mechanics-design.md)
@@ -40,7 +40,7 @@ Verified on the finished branch, not from memory:
The account-page check drove the real page with a seeded `@supabase/ssr`
session cookie (`sb-localhost-auth-token`, `base64-` + base64url JSON): the
section appears only when the account has a wedding, and the button fires a
-real download named `charis-and-jacob.trousseau.json`.
+real download named `charis-and-jacob.knotwork.json`.
No corrections to this plan were needed during execution. The two corrections
it makes to the *spec* are recorded above under "Two corrections to the spec".
@@ -341,27 +341,27 @@ beforeEach(() => {
});
test("a signed-in member can save, and the version advances", async () => {
- const first = await put({ kind: "trousseau", version: 1 }, 0);
+ const first = await put({ kind: "knotwork", version: 1 }, 0);
expect(first.status).toBe(200);
expect(await first.json()).toMatchObject({ version: 1 });
});
test("a stale expected version comes back as a conflict, with the true state", async () => {
- await put({ kind: "trousseau", version: 1 }, 0);
- const second = await put({ kind: "trousseau", version: 1 }, 0);
+ await put({ kind: "knotwork", version: 1 }, 0);
+ const second = await put({ kind: "knotwork", version: 1 }, 0);
expect(second.status).toBe(409);
expect(await second.json()).toMatchObject({ version: 1 });
});
test("a signed-out caller is refused before any document work", async () => {
currentUserResult = null;
- const response = await put({ kind: "trousseau", version: 1 }, 0);
+ const response = await put({ kind: "knotwork", version: 1 }, 0);
expect(response.status).toBe(401);
});
test("an account with no wedding gets 404, not a crash", async () => {
membership = null;
- const response = await put({ kind: "trousseau", version: 1 }, 0);
+ const response = await put({ kind: "knotwork", version: 1 }, 0);
expect(response.status).toBe(404);
});
@@ -369,16 +369,16 @@ test("writes past the limit are throttled, and the budget is per account", async
// WRITE_LIMIT is 600 a minute. Spend it, then confirm the next is refused.
let version = 0;
for (let i = 0; i < 600; i += 1) {
- const response = await put({ kind: "trousseau", version: 1 }, version);
+ const response = await put({ kind: "knotwork", version: 1 }, version);
if (response.status === 200) version += 1;
}
- const refused = await put({ kind: "trousseau", version: 1 }, version);
+ const refused = await put({ kind: "knotwork", version: 1 }, version);
expect(refused.status).toBe(429);
// A different account is unaffected — this is the point of keying by user.
currentUserResult = { id: "someone-else", email: "b@example.com" };
membership = { weddingId: "someone-elses-wedding" };
- const other = await put({ kind: "trousseau", version: 1 }, 0);
+ const other = await put({ kind: "knotwork", version: 1 }, 0);
expect(other.status).toBe(200);
});
```
@@ -411,7 +411,7 @@ git commit -m "Rate limit the authenticated document write path, keyed by accoun
**Interfaces:**
- Consumes: `DocumentStore`, `memoryStore` from `suite/lib/documents/store.ts`;
- `migrate`, `suggestedFilename`, `TROUSSEAU_EXTENSION` from `@jfrusher/trousseau`.
+ `migrate`, `suggestedFilename`, `KNOTWORK_EXTENSION` from `@jfrusher/knotwork`.
- Produces: `exportDocumentHandler(store: DocumentStore, weddingId: string): Promise`,
`interface ExportFile { filename: string; text: string }`, and
`type ExportReply = { status: 200; file: ExportFile } | { status: 404; body: unknown }`.
@@ -436,7 +436,7 @@ describe("exportDocumentHandler", () => {
const reply = await exportDocumentHandler(store, "w1");
expect(reply.status).toBe(200);
if (reply.status !== 200) return;
- expect(reply.file.filename).toBe("charis-and-jacob.trousseau.json");
+ expect(reply.file.filename).toBe("charis-and-jacob.knotwork.json");
expect(JSON.parse(reply.file.text)).toEqual(document);
// Pretty-printed, so a person opening the file can read it.
expect(reply.file.text).toContain("
@@ -462,7 +462,7 @@ describe("exportDocumentHandler", () => {
if (reply.status !== 200) return;
expect(JSON.parse(reply.file.text)).toEqual(broken);
// migrate() threw, so the name falls back instead of the export failing.
- expect(reply.file.filename).toBe("wedding.trousseau.json");
+ expect(reply.file.filename).toBe("wedding.knotwork.json");
});
});
```
@@ -477,7 +477,7 @@ Expected: FAIL — `exportDocumentHandler` is not exported from `./handlers`.
Add to the imports at the top of `suite/lib/documents/handlers.ts`:
```ts
-import { migrate, suggestedFilename, TROUSSEAU_EXTENSION } from "@jfrusher/trousseau";
+import { migrate, suggestedFilename, KNOTWORK_EXTENSION } from "@jfrusher/knotwork";
```
Add at the end of `suite/lib/documents/handlers.ts`:
@@ -526,7 +526,7 @@ function exportFilename(document: unknown): string {
try {
return suggestedFilename(migrate(document));
} catch {
- return `wedding${TROUSSEAU_EXTENSION}`;
+ return `wedding${KNOTWORK_EXTENSION}`;
}
}
```
@@ -575,7 +575,7 @@ import { allow, EXPORT_LIMIT } from "@/lib/sync/rateLimit";
* "Download my wedding" — the honest answer to "can I get my data out".
*
* Also the migration path off the hosted instance: the file this returns is
- * the same `.trousseau.json` a self-hosted instance, or the local-only mode,
+ * the same `.knotwork.json` a self-hosted instance, or the local-only mode,
* will open. There is no export format to keep in step, because there is no
* separate export format.
*/
@@ -669,7 +669,7 @@ vi.mock("@/lib/documents/supabaseStore", () => ({
const route = await import("./route");
const wedding = (coupleNames: string) => ({
- kind: "trousseau",
+ kind: "knotwork",
version: 1,
event: {
date: "2026-08-20",
@@ -698,7 +698,7 @@ test("a member downloads their own wedding as an attachment", async () => {
const response = await route.GET();
expect(response.status).toBe(200);
expect(response.headers.get("content-disposition")).toBe(
- 'attachment; filename="charis-and-jacob.trousseau.json"',
+ 'attachment; filename="charis-and-jacob.knotwork.json"',
);
// Personal data must never sit in a shared cache.
expect(response.headers.get("cache-control")).toContain("no-store");
@@ -794,7 +794,7 @@ section, whose opening tag is
Your data
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
+ the crew and the stationery. It opens in Knotwork anywhere, including your own
copy if you ever run one.