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. + [![Licence: AGPL-3.0 app, MIT contract](https://img.shields.io/badge/licence-AGPL--3.0%20app%20%C2%B7%20MIT%20contract-5b4bd5)](LICENSE) [![GitHub stars](https://img.shields.io/github/stars/JFrusher/Trousseau?style=flat&logo=github&label=stars)](https://github.com/JFrusher/Trousseau/stargazers) [![CI](https://github.com/JFrusher/Trousseau/actions/workflows/ci.yml/badge.svg)](https://github.com/JFrusher/Trousseau/actions/workflows/ci.yml) @@ -13,13 +15,13 @@ [![No account needed](https://img.shields.io/badge/account-not%20needed-2f855a)](https://trousseau-suite.vercel.app) [![PRs welcome](https://img.shields.io/badge/PRs-welcome-e05d44)](CONTRIBUTING.md) -[**Open Trousseau →**](https://trousseau-suite.vercel.app)  ·  +[**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) -![Trousseau: the whole wedding in one place, with the front page showing where things stand](marketing/assets/images/hero-overview.png) +![Knotwork: the whole wedding in one place, with the front page showing where things stand](marketing/assets/images/hero-overview.png)
@@ -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.

@@ -87,7 +87,7 @@

📄

One wedding, one document

-

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

+

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

🔁

@@ -213,14 +213,14 @@

And here's the real th

"Free" isn't the same as free

-

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

+

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

- + @@ -267,8 +267,8 @@

Run your own copy

Read the self-hosting guide →
# local-only: no backend, no account
-git clone https://github.com/JFrusher/Trousseau.git
-cd Trousseau
+git clone https://github.com/JFrusher/Trousseau.git Knotwork
+cd Knotwork
 npm ci
 npm run build
 npm run dev -w suite
@@ -289,7 +289,7 @@ 

Your wedding, in one p
-

Trousseau: app AGPL-3.0-or-later · data contract MIT · +

Knotwork: app AGPL-3.0-or-later · data contract MIT · Privacy · Contribute · Roadmap

diff --git a/package-lock.json b/package-lock.json index 931c18f6..90088faf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@jfrusher/trousseau", + "name": "@jfrusher/knotwork", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@jfrusher/trousseau", + "name": "@jfrusher/knotwork", "version": "0.1.0", "license": "MIT", "workspaces": [ @@ -1138,7 +1138,7 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@jfrusher/trousseau": { + "node_modules/@jfrusher/knotwork": { "resolved": "", "link": true }, @@ -1778,19 +1778,19 @@ "license": "MIT" }, "node_modules/@playwright/test": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", - "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.63.0" + "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=20" + "node": ">=18" } }, "node_modules/@polka/url": { @@ -6367,19 +6367,22 @@ "license": "MIT" }, "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "playwright-core": "1.56.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=20" + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" } }, "node_modules/playwright-core": { @@ -6395,6 +6398,21 @@ "node": ">=18" } }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -8021,7 +8039,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@jfrusher/trousseau": "file:..", + "@jfrusher/knotwork": "file:..", "@pdf-lib/fontkit": "^1.1.1", "@sentry/nextjs": "^10.75.0", "@supabase/ssr": "^0.12.7", @@ -8047,7 +8065,7 @@ "@axe-core/playwright": "4.13.0", "@electric-sql/pglite": "^0.5.8", "@next/bundle-analyzer": "^16.3.7", - "@playwright/test": "1.63.0", + "@playwright/test": "1.56.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", diff --git a/package.json b/package.json index a7924541..3894d64f 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@jfrusher/trousseau", + "name": "@jfrusher/knotwork", "version": "0.1.0", "description": "The shared data contract behind Tableaux, Plaque, Cadence and Brigade.", "license": "MIT", @@ -32,12 +32,12 @@ "test:watch": "vitest", "verify": "npm run build && tsc -p verify/tsconfig.json && node -e \"import('./dist/index.js')\"", "prepublishOnly": "npm run test && npm run verify", - "data:pack": "node scripts/bundle.mjs pack --working -o data/wedding.trousseau.json", - "data:unpack": "node scripts/bundle.mjs unpack data/wedding.trousseau.json -d unpacked", - "data:validate": "npm run build && node scripts/validate-wedding.mjs data/wedding.trousseau.json", + "data:pack": "node scripts/bundle.mjs pack --working -o data/wedding.knotwork.json", + "data:unpack": "node scripts/bundle.mjs unpack data/wedding.knotwork.json -d unpacked", + "data:validate": "npm run build && node scripts/validate-wedding.mjs data/wedding.knotwork.json", "data:status": "python -m dvc status && python -m dvc data status", "data:pull": "python -m dvc pull", - "data:push": "npm run data:validate && python -m dvc add data/wedding.trousseau.json data/exports && python -m dvc push", + "data:push": "npm run data:validate && python -m dvc add data/wedding.knotwork.json data/exports && python -m dvc push", "clean": "git clean -Xdf node_modules dist .next coverage", "sync": "node scripts/sync.mjs" }, @@ -57,6 +57,7 @@ "esbuild@0.28.2": true }, "overrides": { - "playwright-core": "1.56.1" + "playwright-core": "1.56.1", + "@playwright/test": "1.56.1" } } diff --git a/scratch/docs/SETUP.md b/scratch/docs/SETUP.md index acec8e44..a39a7c76 100644 --- a/scratch/docs/SETUP.md +++ b/scratch/docs/SETUP.md @@ -1,4 +1,4 @@ -# Trousseau — from here to hosted +# Knotwork — from here to hosted A runbook. Follow it top to bottom; each stage ends with something you can check before moving on. @@ -30,7 +30,7 @@ onwards is what makes those two work. ## 1. Check it builds here first ```sh -cd C:/Projects/Trousseau +cd C:/Projects/Knotwork npm install # installs the workspace: root package + suite npm test # 87 — the data contract npm test -w suite # 703 — the application @@ -113,7 +113,7 @@ rather than breaking. ### 3.1 Create the project 1. supabase.com → **New project** -2. Name it `trousseau`. Region: whichever is nearest you. +2. Name it `knotwork`. Region: whichever is nearest you. 3. Set a database password and put it in your password manager. You will not need it for this app, but you will need it if you ever open the database directly. @@ -284,7 +284,7 @@ replacing. Export a backup first if you want to keep what is there. > ⚠️ **Publish from the production domain, not a preview deployment.** The link > is built from whatever origin you are on, so one minted on -> `trousseau-abc123.vercel.app` will point at that preview forever — and preview +> `knotwork-abc123.vercel.app` will point at that preview forever — and preview > URLs are not permanent. If you add a custom domain later, republish. The link carries its decryption key after the `#`, which browsers never send to @@ -299,7 +299,7 @@ so a link already given out stays correct. *Take it down* deletes it. ## 7. After setup **Back up.** The Data manager's **Export backup** writes the whole wedding — -guests, seating, the day, the crew, the stationery — to one `.trousseau.json`. +guests, seating, the day, the crew, the stationery — to one `.knotwork.json`. That file is the only copy that survives clearing your browser. Do it after any big session, and keep it somewhere that is not the laptop. diff --git a/scripts/bundle.mjs b/scripts/bundle.mjs index b5795afd..d2e07524 100644 --- a/scripts/bundle.mjs +++ b/scripts/bundle.mjs @@ -1,18 +1,18 @@ #!/usr/bin/env node -// Bundle a handful of native app files into one .trousseau.json, and take it +// Bundle a handful of native app files into one .knotwork.json, and take it // apart again at the other end. // -// Nothing reads a .trousseau.json yet — the apps gain that in Phase 1a. Until +// Nothing reads a .knotwork.json yet — the apps gain that in Phase 1a. Until // they do, this is only useful because it goes both ways: pack on one machine, // unpack on the other, open the native files as normal. // -// node scripts/bundle.mjs pack [-o wedding.trousseau.json] +// node scripts/bundle.mjs pack [-o wedding.knotwork.json] // node scripts/bundle.mjs pack --working [-o …] (the configured working folder) -// node scripts/bundle.mjs unpack [-d outdir] +// node scripts/bundle.mjs unpack [-d outdir] import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from "node:fs"; import { basename, join, resolve } from "node:path"; -import { emptyTrousseau, mergeSlice, migrate, parse, serialise } from "../dist/index.js"; +import { emptyKnotwork, mergeSlice, migrate, parse, serialise } from "../dist/index.js"; // Each app's native file, and how to recognise it. Order matters: the day // export and the Plaque project carry explicit markers, so they are checked @@ -46,7 +46,7 @@ function workingDir() { } console.error("No working folder configured. Either:"); console.error(" set WEDDING_WORKING=, or"); - console.error(" write the path into Trousseau/.working-path (one line, git-ignored)"); + console.error(" write the path into Knotwork/.working-path (one line, git-ignored)"); process.exit(1); } @@ -120,7 +120,8 @@ refusing to write ${out}: it would lose ${lost.join(", ")}.`); } function classify(doc) { - if (doc.kind === "trousseau") return { kind: "bundle" }; + // "trousseau" is the name a bundle carried before the rename. + if (doc.kind === "knotwork" || doc.kind === "trousseau") return { kind: "bundle" }; if (doc.kind === "cadence.day") return { kind: "day" }; const hit = APPS.find((a) => a.is(doc)); return hit ? { kind: "source", app: hit.app } : { kind: "unknown" }; @@ -155,7 +156,7 @@ function eventFrom(doc, what) { } function pack(files, out, { allowShrink = false } = {}) { - let raw = emptyTrousseau(); + let raw = emptyKnotwork(); const sources = {}; const sourceFiles = {}; const claims = []; @@ -184,7 +185,7 @@ function pack(files, out, { allowShrink = false } = {}) { continue; } if (seen.kind === "bundle") { - notes.push(`skipped ${basename(file)} — already a .trousseau.json`); + notes.push(`skipped ${basename(file)} — already a .knotwork.json`); continue; } @@ -251,7 +252,7 @@ function unpack(file, dir) { .toLowerCase() .replace(/&/g, " and ") .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "wedding"; + .replace(/^-|-$/g, "") || "wedding"; for (const [app, native] of Object.entries(doc.sources ?? {})) { const ext = APPS.find((a) => a.app === app)?.ext ?? `.${app}.json`; @@ -278,12 +279,12 @@ const allowShrink = rest.includes("--allow-shrink"); const packArgs = rest.includes("--working") ? ["--working"] : positional; if (mode === "pack" && packArgs.length > 0) { - pack(expand(packArgs), flag("-o", "wedding.trousseau.json"), { allowShrink }); + pack(expand(packArgs), flag("-o", "wedding.knotwork.json"), { allowShrink }); } else if (mode === "unpack" && positional.length === 1) { unpack(positional[0], flag("-d", "unpacked")); } else { - console.error("usage: node scripts/bundle.mjs pack [-o out.trousseau.json]"); - console.error(" node scripts/bundle.mjs pack --working [-o out.trousseau.json]"); - console.error(" node scripts/bundle.mjs unpack [-d outdir]"); + console.error("usage: node scripts/bundle.mjs pack [-o out.knotwork.json]"); + console.error(" node scripts/bundle.mjs pack --working [-o out.knotwork.json]"); + console.error(" node scripts/bundle.mjs unpack [-d outdir]"); process.exit(1); } diff --git a/scripts/sync.mjs b/scripts/sync.mjs index 87e6ee5e..b22776a5 100644 --- a/scripts/sync.mjs +++ b/scripts/sync.mjs @@ -18,8 +18,8 @@ const dry = process.argv.includes("--dry-run"); // Passed through to pack. Needed when a slice is genuinely going away — an app // whose file has not been exported yet, say — rather than missing by accident. const allowShrink = process.argv.includes("--allow-shrink"); -const POINTER = "data/wedding.trousseau.json.dvc"; -const BUNDLE = "data/wedding.trousseau.json"; +const POINTER = "data/wedding.knotwork.json.dvc"; +const BUNDLE = "data/wedding.knotwork.json"; /** * Run a command, streaming its output. Returns true on exit 0. diff --git a/scripts/validate-wedding.mjs b/scripts/validate-wedding.mjs index 3a738a83..ea27add7 100644 --- a/scripts/validate-wedding.mjs +++ b/scripts/validate-wedding.mjs @@ -1,12 +1,12 @@ // The gate on the canonical wedding file. // -// Trousseau's zod schema validates the envelope: kind, version, event, and the +// Knotwork's zod schema validates the envelope: kind, version, event, and the // fact that each slice is an object. It deliberately cannot validate slice // interiors — that shape belongs to the owning app (see src/slices.ts). The // cross-slice invariants live here instead, because they are the ones no single // app can check: an app only ever sees its own slice. // -// node scripts/validate-wedding.mjs [data/wedding.trousseau.json] +// node scripts/validate-wedding.mjs [data/wedding.knotwork.json] // // Exit 1 on any error. Warnings print and exit 0. // @@ -23,7 +23,7 @@ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v); * Every cross-slice invariant, as a pure function so it can be tested without * a file or a process exit. * - * @param doc a parsed trousseau + * @param doc a parsed Knotwork document * @returns {{errors: string[], warnings: string[], facts: string[]}} */ export function check(doc) { @@ -209,12 +209,12 @@ export function check(doc) { // Not run when imported by the test. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const file = process.argv[2] ?? "data/wedding.trousseau.json"; + const file = process.argv[2] ?? "data/wedding.knotwork.json"; let doc; try { doc = parse(readFileSync(file, "utf8")); } catch (e) { - console.error(`${file} does not parse against the trousseau schema:\n ${e.message}`); + console.error(`${file} does not parse against the Knotwork schema:\n ${e.message}`); process.exit(1); } diff --git a/scripts/validate-wedding.test.mjs b/scripts/validate-wedding.test.mjs index 32cd8720..e6cc2a54 100644 --- a/scripts/validate-wedding.test.mjs +++ b/scripts/validate-wedding.test.mjs @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { check } from "./validate-wedding.mjs"; -/** A trousseau carrying one Tableaux table and the guests sitting at it. */ +/** A Knotwork document carrying one Tableaux table and the guests sitting at it. */ const withTable = (table, guests) => ({ event: { date: "2026-06-20" }, day: null, diff --git a/src/envelope.test.ts b/src/envelope.test.ts index 6e2ba783..390973bb 100644 --- a/src/envelope.test.ts +++ b/src/envelope.test.ts @@ -1,26 +1,26 @@ import { describe, expect, it } from "vitest"; import { SLICE_NAMES, - TROUSSEAU_KIND, - TROUSSEAU_VERSION, - emptyTrousseau, + KNOTWORK_KIND, + KNOTWORK_VERSION, + emptyKnotwork, migrate, - trousseauSchema, + knotworkSchema, } from "./envelope.js"; -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(""); }); }); @@ -51,15 +51,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(); }); @@ -67,3 +67,13 @@ describe("migrate", () => { expect(() => migrate({ guests: "everyone" })).toThrow(); }); }); + +describe("documents saved before the rename", () => { + it("still read, and read as Knotwork", () => { + expect(migrate({ kind: "trousseau", version: 1 }).kind).toBe(KNOTWORK_KIND); + }); + + it("are not confused with anything else", () => { + expect(() => migrate({ kind: "cadence.day" })).toThrow(); + }); +}); diff --git a/src/envelope.ts b/src/envelope.ts index 8972e276..27c68a44 100644 --- a/src/envelope.ts +++ b/src/envelope.ts @@ -3,8 +3,14 @@ import { daySchema } from "./day.js"; import { eventSchema } from "./event.js"; import { barSchema, boxesSchema, castSchema, ceremonySchema, crewSchema, guestsSchema, seatingSchema, shotsSchema, stationerySchema, timelineSchema, toolsSchema } from "./slices.js"; -export const TROUSSEAU_KIND = "trousseau"; -export const TROUSSEAU_VERSION = 1; +export const KNOTWORK_KIND = "knotwork"; +export const KNOTWORK_VERSION = 1; + +/** + * What `kind` said before the rename. Every file exported, every copy on a + * device and every synced document still says it, and is the same document. + */ +export const LEGACY_KINDS: readonly string[] = ["trousseau"]; /** * The slices an app may publish. `sources` is deliberately absent: it is not @@ -37,9 +43,12 @@ 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.preprocess( + (kind) => (typeof kind === "string" && LEGACY_KINDS.includes(kind) ? KNOTWORK_KIND : kind), + z.literal(KNOTWORK_KIND).default(KNOTWORK_KIND), + ), + version: z.number().default(KNOTWORK_VERSION), event: eventSchema.default(() => eventSchema.parse({})), guests: guestsSchema, seating: seatingSchema, @@ -58,11 +67,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({}); } /** @@ -74,16 +83,16 @@ 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); } /** * 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 @@ -104,8 +113,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, }; } diff --git a/src/file.test.ts b/src/file.test.ts index 1e6d3c7b..4b2ac37c 100644 --- a/src/file.test.ts +++ b/src/file.test.ts @@ -1,26 +1,26 @@ import { describe, expect, it } from "vitest"; -import { emptyTrousseau } from "./envelope.js"; -import { TROUSSEAU_EXTENSION, parse, serialise, suggestedFilename } from "./file.js"; +import { emptyKnotwork } from "./envelope.js"; +import { KNOTWORK_EXTENSION, parse, serialise, suggestedFilename } from "./file.js"; 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" } }); }); @@ -30,41 +30,62 @@ 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/); }); it("carries an unknown slice through serialise and back", () => { - const doc = { ...emptyTrousseau(), florals: { arch: "peonies", budget: 1200 } }; + const doc = { ...emptyKnotwork(), florals: { arch: "peonies", budget: 1200 } }; const back = parse(serialise(doc as Parameters[0])); expect(back).toMatchObject({ florals: { arch: "peonies", budget: 1200 } }); }); it("parses a document with no kind at all, since kind has a default", () => { - expect(parse(JSON.stringify({ version: 1 })).kind).toBe("trousseau"); + expect(parse(JSON.stringify({ version: 1 })).kind).toBe("knotwork"); }); it("refuses a JSON array", () => { - expect(() => parse("[]")).toThrow(/not a Trousseau file/); + expect(() => parse("[]")).toThrow(/not a Knotwork file/); }); it("refuses JSON null", () => { - expect(() => parse("null")).toThrow(/not a Trousseau file/); + expect(() => parse("null")).toThrow(/not a Knotwork file/); }); it("explains a malformed slice rather than dumping a validation error", () => { - const bad = JSON.stringify({ kind: "trousseau", version: 1, guests: "oops" }); + const bad = JSON.stringify({ kind: "knotwork", version: 1, guests: "oops" }); expect(() => parse(bad)).toThrow(/could not be read/); }); }); 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}`); + }); +}); + +describe("a file exported before the rename", () => { + it("opens", () => { + expect(parse(JSON.stringify({ kind: "trousseau", version: 1 })).kind).toBe("knotwork"); + }); +}); + +describe("suggestedFilename on awkward names", () => { + it("trims separators from both ends and keeps one between words", () => { + expect(suggestedFilename({ ...emptyKnotwork(), event: { ...emptyKnotwork().event, coupleNames: " --Ann & Bo!! " } })).toBe( + "ann-and-bo.knotwork.json", + ); + }); + + it("stays linear on a name that is mostly separators", () => { + const coupleNames = `a${"-".repeat(100_000)}b`; + const started = performance.now(); + expect(suggestedFilename({ ...emptyKnotwork(), event: { ...emptyKnotwork().event, coupleNames } })).toBe("a-b.knotwork.json"); + expect(performance.now() - started).toBeLessThan(100); }); }); diff --git a/src/file.ts b/src/file.ts index 98db9290..97587a95 100644 --- a/src/file.ts +++ b/src/file.ts @@ -1,54 +1,54 @@ import { ZodError } from "zod"; -import { TROUSSEAU_KIND, migrate, type Trousseau } from "./envelope.js"; +import { KNOTWORK_KIND, LEGACY_KINDS, migrate, type Knotwork } from "./envelope.js"; -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 && !LEGACY_KINDS.includes(String(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)}".`, ); } try { return migrate(raw); } catch (cause) { - throw new Error(`That Trousseau file could not be read: ${firstIssue(cause)}`, { cause }); + throw new Error(`That Knotwork file could not be read: ${firstIssue(cause)}`, { cause }); } } -/** `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}`; + .replace(/^-|-$/g, ""); + return `${slug || "wedding"}${KNOTWORK_EXTENSION}`; } /** The most useful line out of a validation failure, for a person rather than a log. */ diff --git a/src/index.ts b/src/index.ts index 4e33026c..48f3db13 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,14 +12,14 @@ export { } from "./day.js"; 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.js"; export { barSchema, @@ -46,7 +46,7 @@ export { type Tools, } from "./slices.js"; export { - TROUSSEAU_EXTENSION, + KNOTWORK_EXTENSION, parse, serialise, suggestedFilename, diff --git a/src/preservation.test.ts b/src/preservation.test.ts index 08f4050d..5687e396 100644 --- a/src/preservation.test.ts +++ b/src/preservation.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { SLICE_NAMES, mergeSlice, migrate, trousseauSchema } from "./envelope.js"; +import { SLICE_NAMES, mergeSlice, migrate, knotworkSchema } from "./envelope.js"; /** 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" } }, @@ -52,7 +52,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. @@ -83,13 +83,13 @@ describe("mergeSlice writes the slice it is given", () => { } it("keeps an existing version rather than resetting it", () => { - const merged = mergeSlice({ kind: "trousseau", version: 7 }, "crew", {}); + const merged = mergeSlice({ kind: "knotwork", version: 7 }, "crew", {}); expect(merged["version"]).toBe(7); }); it("stamps kind and version onto a document that has neither", () => { const merged = mergeSlice({}, "crew", {}); - expect(merged["kind"]).toBe("trousseau"); + expect(merged["kind"]).toBe("knotwork"); expect(merged["version"]).toBe(1); }); }); diff --git a/suite/README.md b/suite/README.md index f7abbf9c..78d55628 100644 --- a/suite/README.md +++ b/suite/README.md @@ -1,12 +1,13 @@ -# Trousseau +# Knotwork Seating, stationery, timeline and crew for one wedding, in one application. -> Briefly called Tableaux Suite, which it shared with one of the four apps it -> replaces. The name it has now is the repo it lives in — and the metaphor was -> always about this: a trousseau is the collection carried into a marriage. -> Stored data written under the old name is moved on first load by -> [`lib/store/migrateKeys.ts`](lib/store/migrateKeys.ts). +> Formerly Trousseau, and before that, briefly, Tableaux Suite, which it shared +> with one of the four apps it replaces. Knotwork is the name for many strands +> worked into one — which is what this does to a wedding's lists. Stored data +> written under either old name is moved on first load by +> [`lib/store/migrateKeys.ts`](lib/store/migrateKeys.ts), and files exported as +> Trousseau still open. > > Two things kept the old name on purpose. The HKDF labels in > [`lib/sync/crypto.ts`](lib/sync/crypto.ts) are opaque protocol constants that @@ -20,7 +21,7 @@ This is the four standalone apps — [Tableaux](https://github.com/JFrusher/Tabl [Plaque](https://github.com/JFrusher/Plaque), [Cadence](https://github.com/JFrusher/cadence) and [Brigade](https://github.com/JFrusher/Brigade) — brought onto one document, the -`.trousseau.json` whose contract lives in the package above this directory. +`.knotwork.json` whose contract lives in the package above this directory. ## Why it is here @@ -32,8 +33,8 @@ number, move a block of the day and every job hanging off it moves with it. ## The store -One Zustand store, [`lib/store/useTrousseauStore.ts`](lib/store/useTrousseauStore.ts), -holding the Trousseau envelope rather than a flat bag of entities: +One Zustand store, [`lib/store/useKnotworkStore.ts`](lib/store/useKnotworkStore.ts), +holding the Knotwork envelope rather than a flat bag of entities: | Slice | What is in it | Written by | | --- | --- | --- | diff --git a/suite/app/(app)/account/page.tsx b/suite/app/(app)/account/page.tsx index 1662c45d..d96a6bf7 100644 --- a/suite/app/(app)/account/page.tsx +++ b/suite/app/(app)/account/page.tsx @@ -9,7 +9,7 @@ import { useConfirm } from "@/components/ui/Confirm"; import { removeWeddingFromDevice } from "@/lib/store/removeFromDevice"; import { SignInFailed } from "@/components/shell/SignInFailed"; import { WeddingPeople } from "@/components/shell/WeddingPeople"; -import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; +import { useKnotworkStore } from "@/lib/store/useKnotworkStore"; import type { WeddingListing } from "@/lib/accounts/handlers"; type AccountState = { signedIn: false } | { signedIn: true; me: string; weddings: WeddingListing[] }; @@ -35,7 +35,7 @@ export default function AccountPage() { const client = browserClient(); const confirm = useConfirm(); // The wedding open on this device — the one "who has access" is about. - const open = useTrousseauStore((s) => s.weddingId); + const open = useKnotworkStore((s) => s.weddingId); const say = useCallback((text: string, tone: "ok" | "error") => setNotice({ text, tone }), []); useEffect(() => { @@ -108,7 +108,7 @@ export default function AccountPage() { return (
-

Trousseau

+

Knotwork

Your account

{!client ? ( @@ -165,7 +165,7 @@ export default function AccountPage() {

Your data

Download the wedding open here as one file — guests, seating, the day, the - crew and the stationery. It opens in Trousseau anywhere, including your own + crew and the stationery. It opens in Knotwork anywhere, including your own copy if you ever run one.

- {error && ( -

- {error} +

+ {next?.startsWith("/invite/") && ( + // An invite opens only for the address it was sent to, and Apple's + // Hide My Email signs in with a relay address that never matches. +

+ Sign in with the address your invite was sent to. With Apple, choose{" "} + Share My Email.

)} - +
+ {PROVIDERS.map(({ id, label, mark: Mark }) => ( + + ))} +
+
+ + or + +
+
{ + event.preventDefault(); + void sendCode(); + }} + className="space-y-4" + > +

{description}

+ + + {error && ( +

+ {error} +

+ )} + +
); } return (
-

Trousseau

+

Knotwork

Sign in

{content}
diff --git a/suite/app/(app)/page.tsx b/suite/app/(app)/page.tsx index 3b921e10..6e758f07 100644 --- a/suite/app/(app)/page.tsx +++ b/suite/app/(app)/page.tsx @@ -2,38 +2,40 @@ import type { Metadata } from "next"; import { Overview } from "@/components/shell/Overview"; import { Countdown } from "@/components/shell/Countdown"; import { WeddingPack } from "@/components/shell/WeddingPack"; -import { SetupPrompt } from "@/components/shell/SetupPrompt"; +import { ExampleBanner } from "@/components/shell/ExampleBanner"; +import { FrontPage } from "@/components/shell/FrontPage"; export const metadata: Metadata = { // `absolute` so the root template does not append the suffix to the name it // is a suffix of. - title: { absolute: "Trousseau" }, + title: { absolute: "Knotwork" }, description: "Seating, stationery, timeline and crew for one wedding.", }; /** - * The wedding at a glance. + * The wedding at a glance — or, while there is no wedding yet, the welcome. * - * This page used to sell the app: a headline, three columns about local-first - * storage, a licence. That was written for someone deciding whether to use it. - * There is one person using it, they decided, and they now open this page to - * find out where things stand — so it answers that instead. - * - * What was true in the pitch has not been deleted so much as demoted: the - * promises about nothing being uploaded are kept where they are actually load - * bearing, in the Data panel, next to the buttons they describe. + * Someone with a wedding opens this page to find out where things stand, so + * that is what it answers: no pitch, the promises about nothing being uploaded + * kept in the Data panel next to the buttons they describe. Someone with + * nothing in it yet has a different question — what is this, and how do I try + * it? — and gets `Welcome` until anything is in the wedding (see `FrontPage`). */ export default function Home() { return ( -
- - + + + - + -
- -
-
+
+ +
+
+ } + /> ); } diff --git a/suite/app/(app)/setup/page.tsx b/suite/app/(app)/setup/page.tsx index 7c4f38d7..9856801a 100644 --- a/suite/app/(app)/setup/page.tsx +++ b/suite/app/(app)/setup/page.tsx @@ -8,7 +8,7 @@ import { eventChange } from "@/lib/model/useSuite"; import { readGuests } from "@/lib/model/slices"; import type { Guest } from "@/lib/model/types"; import { startingRoom, SEATS, tablesFor, withPasted, type StartingTable } from "@/lib/setup/draft"; -import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; +import { useKnotworkStore } from "@/lib/store/useKnotworkStore"; import { Button, TextField } from "@/components/ui/controls"; import { useGuestImport, type ImportTarget } from "@/components/shell/guestImportPanel"; import { WeddingPeople } from "@/components/shell/WeddingPeople"; @@ -38,7 +38,7 @@ interface Draft { * the page to sign in, so it comes after the commit, never before. */ export default function SetupPage() { - const status = useTrousseauStore((s) => s.status); + const status = useKnotworkStore((s) => s.status); if (status !== "ready") return
; return ; } @@ -47,7 +47,7 @@ function Setup() { // From the wedding as it is: running setup again adds to it, and never // throws away what is there. const [draft, setDraft] = useState(() => { - const { doc, raw } = useTrousseauStore.getState(); + const { doc, raw } = useKnotworkStore.getState(); const seating = raw["seating"]; return { partners: doc.event.partners, @@ -125,7 +125,7 @@ function Guests({ draft, setDraft, onNext }: StepProps) { const intoDraft: ImportTarget = { read: () => ({ - event: { ...useTrousseauStore.getState().doc.event, partners: current.current.partners }, + event: { ...useKnotworkStore.getState().doc.event, partners: current.current.partners }, guests: current.current.guests, seating: current.current.seating, }), @@ -185,7 +185,7 @@ function Room({ draft, onDone }: { draft: Draft; onDone: () => void }) { const [choice, setChoice] = useState(existing > 0 ? "later" : "round"); function commit() { - const { doc, setSlices } = useTrousseauStore.getState(); + const { doc, setSlices } = useKnotworkStore.getState(); const seating = choice === "later" ? draft.seating : startingRoom(draft.seating, choice, tablesFor(guests, choice)); setSlices( [ @@ -232,7 +232,7 @@ function Room({ draft, onDone }: { draft: Draft; onDone: () => void }) { function Together() { const client = browserClient(); - const weddingId = useTrousseauStore((s) => s.weddingId); + const weddingId = useKnotworkStore((s) => s.weddingId); const [me, setMe] = useState(undefined); const [notice, setNotice] = useState<{ text: string; tone: "ok" | "error" } | null>(null); diff --git a/suite/app/(app)/weddings/page.tsx b/suite/app/(app)/weddings/page.tsx index 886353f3..40b3ede0 100644 --- a/suite/app/(app)/weddings/page.tsx +++ b/suite/app/(app)/weddings/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import { Plus } from "lucide-react"; import { browserClient } from "@/lib/accounts/browserClient"; -import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; +import { useKnotworkStore } from "@/lib/store/useKnotworkStore"; import type { WeddingListing } from "@/lib/accounts/handlers"; import { Button } from "@/components/ui/controls"; import { todayIso } from "@/lib/dates"; @@ -19,7 +19,7 @@ import { WeddingList } from "@/components/weddings/WeddingList"; */ export default function WeddingsPage() { const client = browserClient(); - const open = useTrousseauStore((s) => s.weddingId); + const open = useKnotworkStore((s) => s.weddingId); const [weddings, setWeddings] = useState(null); const [problem, setProblem] = useState(null); @@ -54,7 +54,7 @@ export default function WeddingsPage() { return (
-

Trousseau

+

Knotwork

Your weddings

{!client ? ( diff --git a/suite/app/api/cron/sweep/route.ts b/suite/app/api/cron/sweep/route.ts index 531a21b0..b8b07ed6 100644 --- a/suite/app/api/cron/sweep/route.ts +++ b/suite/app/api/cron/sweep/route.ts @@ -34,12 +34,12 @@ export async function GET(request: Request) { try { const { deleted } = await sweepAbandonedDocuments(documentStore(adminClient)); // A count only: ids identify rows, and a log is no place for them. - requestLog(request).info({ deleted: deleted.length }, "[Trousseau] retention sweep"); + requestLog(request).info({ deleted: deleted.length }, "[Knotwork] retention sweep"); return NextResponse.json({ deleted: deleted.length }); } catch (cause) { // A failed sweep must be loud: it deletes, it runs unattended, and silence // here means data kept past the period the Privacy Policy states. - requestLog(request).error({ err: cause }, "[Trousseau] retention sweep failed"); + requestLog(request).error({ err: cause }, "[Knotwork] retention sweep failed"); return NextResponse.json({ error: "The sweep failed." }, { status: 503 }); } } diff --git a/suite/app/api/documents/export/route.test.ts b/suite/app/api/documents/export/route.test.ts index 11d73344..0e5378c1 100644 --- a/suite/app/api/documents/export/route.test.ts +++ b/suite/app/api/documents/export/route.test.ts @@ -58,7 +58,7 @@ test("a member downloads their own wedding as an attachment", async () => { const response = await exported(); 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"); diff --git a/suite/app/api/documents/export/route.ts b/suite/app/api/documents/export/route.ts index 884e724c..177c2ee8 100644 --- a/suite/app/api/documents/export/route.ts +++ b/suite/app/api/documents/export/route.ts @@ -11,7 +11,7 @@ import { requestLog } from "@/lib/server/log"; * "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. */ diff --git a/suite/app/api/documents/route.test.ts b/suite/app/api/documents/route.test.ts index fdf08f87..fd572d86 100644 --- a/suite/app/api/documents/route.test.ts +++ b/suite/app/api/documents/route.test.ts @@ -52,27 +52,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("a wedding the caller is not on 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); }); @@ -80,16 +80,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); }); @@ -99,7 +99,7 @@ test("a request that names no wedding gets 404 — an account may be on several" }); test("the wedding travels with its document", async () => { - await put({ kind: "trousseau", version: 1 }, 0); + await put({ kind: "knotwork", version: 1 }, 0); const response = await route.GET(new Request(`http://localhost/api/documents?wedding=${membership!.weddingId}`)); expect(await response.json()).toMatchObject({ weddingId: membership!.weddingId, version: 1 }); }); diff --git a/suite/app/auth/callback/route.test.ts b/suite/app/auth/callback/route.test.ts index d4042bd8..147ae7d4 100644 --- a/suite/app/auth/callback/route.test.ts +++ b/suite/app/auth/callback/route.test.ts @@ -1,5 +1,12 @@ -import { describe, expect, it } from "vitest"; -import { sameOriginPath, startsAWedding } from "./route"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getUser = vi.fn(); +const firstSignInHandler = vi.fn(async () => ({ status: 200, body: {} })); +vi.mock("@/lib/accounts/serverClient", () => ({ serverClient: async () => ({ auth: { getUser } }) })); +vi.mock("@/lib/accounts/handlers", () => ({ firstSignInHandler })); +vi.mock("@/lib/accounts/supabaseStore", () => ({ accountsStore: () => ({}) })); + +const { GET, sameOriginPath, startsAWedding } = await import("./route"); const origin = "https://good.example"; @@ -50,3 +57,32 @@ describe("startsAWedding", () => { expect(startsAWedding("/weddings")).toBe(false); }); }); + +describe("GET", () => { + beforeEach(() => { + getUser.mockReset(); + firstSignInHandler.mockClear(); + }); + + it("reports a provider's refusal as a failed sign-in, not a silent return", async () => { + const response = await GET( + new Request(`${origin}/auth/callback?error=access_denied&error_description=cancelled&next=%2Fweddings`), + ); + expect(response.headers.get("location")).toBe(`${origin}/weddings?signin=failed`); + expect(firstSignInHandler).not.toHaveBeenCalled(); + }); + + it("finishes a sign-in already made in the browser — an emailed code — by starting the wedding", async () => { + getUser.mockResolvedValue({ data: { user: { id: "u1" } }, error: null }); + const response = await GET(new Request(`${origin}/auth/callback`)); + expect(firstSignInHandler).toHaveBeenCalledWith(expect.anything(), "u1"); + expect(response.headers.get("location")).toBe(`${origin}/account`); + }); + + it("with no session and nothing to exchange, just goes on", async () => { + getUser.mockResolvedValue({ data: { user: null }, error: null }); + const response = await GET(new Request(`${origin}/auth/callback?next=%2Fguests`)); + expect(firstSignInHandler).not.toHaveBeenCalled(); + expect(response.headers.get("location")).toBe(`${origin}/guests`); + }); +}); diff --git a/suite/app/auth/callback/route.ts b/suite/app/auth/callback/route.ts index 72f90a36..6a70ee9f 100644 --- a/suite/app/auth/callback/route.ts +++ b/suite/app/auth/callback/route.ts @@ -8,9 +8,10 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** - * Where every magic link lands — sign-in and partner invites alike. + * Where every sign-in lands — an emailed code or link, Google and Apple, and + * partner invites alike. * - * Supabase's link carries a PKCE `code`, not a session: it has to be exchanged + * Supabase's redirect carries a PKCE `code`, not a session: it has to be exchanged * here, server-side, so the session cookies are set on the response before any * page renders. `next` is where to go once that's done (the invite page, for * an invite), so pages downstream can assume a session already exists. @@ -57,16 +58,19 @@ export async function GET(request: Request) { const code = url.searchParams.get("code"); const tokenHash = url.searchParams.get("token_hash"); const next = url.searchParams.get("next"); + // A provider that refused or was cancelled (Google, Apple) returns here with + // `error` and no code — a failed sign-in, not an absent one. + const providerError = url.searchParams.get("error"); const destination = sameOriginPath(next, url.origin); - let failed = false; + let failed = Boolean(providerError); let client: Awaited> = null; let userId: string | null = null; try { client = await serverClient(); if (!client) { - failed = Boolean(code || tokenHash); + failed ||= Boolean(code || tokenHash); } else if (tokenHash) { // The email template can send a token hash instead of a PKCE code. This // one carries everything needed with it, so the link works in whatever @@ -80,6 +84,12 @@ export async function GET(request: Request) { const { data, error } = await client.auth.exchangeCodeForSession(code); failed = Boolean(error); userId = data.user?.id ?? null; + } else if (!providerError) { + // Already signed in, in the browser: the emailed six-digit code is + // checked there, and the login page comes here afterwards so a first + // sign-in starts its wedding the same way every other one does. + const { data } = await client.auth.getUser(); + userId = data.user?.id ?? null; } } catch (error) { diff --git a/suite/app/blog/[slug]/page.tsx b/suite/app/blog/[slug]/page.tsx index 82721131..278b33c4 100644 --- a/suite/app/blog/[slug]/page.tsx +++ b/suite/app/blog/[slug]/page.tsx @@ -35,8 +35,8 @@ export default async function BlogPost({ params }: Props) { headline: post.title, description: post.description, datePublished: post.published, - author: { "@type": post.author === "Trousseau" ? "Organization" : "Person", name: post.author }, - publisher: { "@type": "Organization", name: "Trousseau" }, + author: { "@type": post.author === "Knotwork" ? "Organization" : "Person", name: post.author }, + publisher: { "@type": "Organization", name: "Knotwork" }, mainEntityOfPage: `${siteUrl()}/blog/${post.slug}`, }; diff --git a/suite/app/blog/page.tsx b/suite/app/blog/page.tsx index 9a71c463..73319356 100644 --- a/suite/app/blog/page.tsx +++ b/suite/app/blog/page.tsx @@ -18,7 +18,7 @@ const dated = (iso: string) => new Date(iso).toLocaleDateString("en-GB", { day: export default function Blog() { return (
-

Trousseau

+

Knotwork

Guides and stories

Plain answers to the questions planning a UK wedding throws up, each checked against its sources — and stories from couples @@ -53,7 +53,7 @@ export default function Blog() {

- Plan your wedding in Trousseau — free, and no account needed + Plan your wedding in Knotwork — free, and no account needed

diff --git a/suite/app/blog/share/page.tsx b/suite/app/blog/share/page.tsx index 4d447ef4..6a693989 100644 --- a/suite/app/blog/share/page.tsx +++ b/suite/app/blog/share/page.tsx @@ -18,7 +18,7 @@ const PROMPTS = [ "How you would like to be named: first names, initials, or not at all", ]; -const mailto = `mailto:${CONTROLLER.email}?subject=${encodeURIComponent("Our wedding story, for the Trousseau blog")}&body=${encodeURIComponent( +const mailto = `mailto:${CONTROLLER.email}?subject=${encodeURIComponent("Our wedding story, for the Knotwork blog")}&body=${encodeURIComponent( PROMPTS.map((prompt) => `${prompt}:\n\n`).join(""), )}`; diff --git a/suite/app/error.tsx b/suite/app/error.tsx index ed828c87..64029c12 100644 --- a/suite/app/error.tsx +++ b/suite/app/error.tsx @@ -23,7 +23,7 @@ export default function AppError({ // Sentry, when it is configured, picks this up through the same handler as // any other unhandled error. Kept as a console error so an unconfigured // deployment still leaves something to read. - console.error("[Trousseau]", error); + console.error("[Knotwork]", error); }, [error]); return ( diff --git a/suite/app/global-error.tsx b/suite/app/global-error.tsx index e2d6816e..d4414f92 100644 --- a/suite/app/global-error.tsx +++ b/suite/app/global-error.tsx @@ -16,7 +16,7 @@ export default function GlobalError({ error: Error & { digest?: string }; }) { useEffect(() => { - console.error("[Trousseau] fatal", error); + console.error("[Knotwork] fatal", error); }, [error]); return ( @@ -37,7 +37,7 @@ export default function GlobalError({ >

- Trousseau could not start + Knotwork could not start

Your wedding is still saved in this browser. Nothing has been lost, and nothing has diff --git a/suite/app/globals.css b/suite/app/globals.css index 8c6a2715..f2f4cd4a 100644 --- a/suite/app/globals.css +++ b/suite/app/globals.css @@ -1,7 +1,7 @@ @import "tailwindcss"; /** - * The Trousseau palette, as Tailwind v4 theme tokens. + * The Knotwork palette, as Tailwind v4 theme tokens. * * Named by role as well as by colour, because the roles are what the UI reads: * a panel is `bg-stone`, not `bg-[#F4F1EA]`. The hexes are the same ones the @@ -108,7 +108,7 @@ * The variables Plaque's ported renderers reference. * * Its card canvas and sheet preview came across unaltered, and they paint with - * a handful of named colours. Mapping those onto the Trousseau palette here is + * a handful of named colours. Mapping those onto the Knotwork palette here is * one block; editing the colour out of every ported file would have been a * hundred small edits and a permanent merge conflict with the original. */ diff --git a/suite/app/layout.tsx b/suite/app/layout.tsx index c6d0833b..74d64e15 100644 --- a/suite/app/layout.tsx +++ b/suite/app/layout.tsx @@ -31,21 +31,21 @@ export const metadata: Metadata = { // resolve against whatever host they happened to fetch from. metadataBase: new URL(siteUrl()), title: { - default: "Trousseau", + default: "Knotwork", // Pages set their own; this keeps the suffix in one place for the rest. - template: "%s · Trousseau", + template: "%s · Knotwork", }, description, - applicationName: "Trousseau", + applicationName: "Knotwork", alternates: { canonical: "/" }, openGraph: { type: "website", - siteName: "Trousseau", - title: "Trousseau", + siteName: "Knotwork", + title: "Knotwork", description, locale: "en_GB", }, - twitter: { card: "summary_large_image", title: "Trousseau", description }, + twitter: { card: "summary_large_image", title: "Knotwork", description }, }; export default function RootLayout({ diff --git a/suite/app/legal.tsx b/suite/app/legal.tsx index 0d74de3d..fabee51b 100644 --- a/suite/app/legal.tsx +++ b/suite/app/legal.tsx @@ -12,7 +12,7 @@ import type { Policy } from "@/lib/legal"; export function PolicyPage({ policy }: { policy: Policy }) { return (

-

Trousseau

+

Knotwork

{policy.title}

Last updated{" "} @@ -43,7 +43,7 @@ export function PolicyPage({ policy }: { policy: Policy }) { href="/" className="inline-flex min-h-11 items-center underline underline-offset-2 hover:text-charcoal" > - Back to Trousseau + Back to Knotwork

diff --git a/suite/app/not-found.tsx b/suite/app/not-found.tsx index 3f36e2ad..6e11aa29 100644 --- a/suite/app/not-found.tsx +++ b/suite/app/not-found.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; export const metadata = { - title: "Not found · Trousseau", + title: "Not found · Knotwork", robots: { index: false, follow: false }, }; @@ -10,7 +10,7 @@ export const metadata = { * * One mistyped a path in their own planning app, and wants the way back. The * other followed a guest link that has been taken down or republished, and does - * not know what Trousseau is — so this must not read as an application error + * not know what Knotwork is — so this must not read as an application error * they caused, and must not offer them the planning tools. */ export default function NotFound() { diff --git a/suite/app/open/[wedding]/page.tsx b/suite/app/open/[wedding]/page.tsx index 0352ed8a..3f852436 100644 --- a/suite/app/open/[wedding]/page.tsx +++ b/suite/app/open/[wedding]/page.tsx @@ -23,7 +23,7 @@ export default function OpenWeddingPage({ params }: { params: Promise<{ wedding: return (
-

Trousseau

+

Knotwork

{problem ? (

That wedding could not be opened: {problem} diff --git a/suite/app/opengraph-image.tsx b/suite/app/opengraph-image.tsx index add2cd49..733bead3 100644 --- a/suite/app/opengraph-image.tsx +++ b/suite/app/opengraph-image.tsx @@ -1,6 +1,6 @@ import { ImageResponse } from "next/og"; -export const alt = "Trousseau — seating, stationery, timeline and crew for one wedding"; +export const alt = "Knotwork — seating, stationery, timeline and crew for one wedding"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; @@ -28,7 +28,7 @@ export default function OpengraphImage() { fontFamily: "Georgia, serif", }} > -

Trousseau
+
Knotwork
One wedding. Four tools. One document.
diff --git a/suite/app/support/page.tsx b/suite/app/support/page.tsx index f5469620..5c0c0b94 100644 --- a/suite/app/support/page.tsx +++ b/suite/app/support/page.tsx @@ -4,7 +4,7 @@ import { KO_FI_URL } from "@/lib/support"; export const metadata: Metadata = { title: "Support", - description: "Trousseau is free and stays free. A tip helps keep the hosted copy running.", + description: "Knotwork is free and stays free. A tip helps keep the hosted copy running.", alternates: { canonical: "/support" }, }; @@ -15,11 +15,11 @@ export const metadata: Metadata = { export default function Support() { return (
-

Trousseau

-

Support Trousseau

+

Knotwork

+

Support Knotwork

- Trousseau is free, and it will stay free. Every tool works in full without an account or a + Knotwork is free, and it will stay free. Every tool works in full without an account or a payment, and nothing will ever be put behind one.

@@ -67,7 +67,7 @@ export default function Support() { href="/" className="inline-flex min-h-11 items-center underline underline-offset-2 hover:text-charcoal" > - Back to Trousseau + Back to Knotwork

diff --git a/suite/apps/brigade/state/live.test.ts b/suite/apps/brigade/state/live.test.ts index 2181e134..3503f9e4 100644 --- a/suite/apps/brigade/state/live.test.ts +++ b/suite/apps/brigade/state/live.test.ts @@ -4,8 +4,8 @@ import { beforeEach, expect, test, vi } from "vitest"; vi.mock("idb-keyval", () => ({ get: async () => undefined, set: async () => undefined, del: async () => undefined })); -const { migrate } = await import("@jfrusher/trousseau"); -const { useTrousseauStore } = await import("@/lib/store/useTrousseauStore"); +const { migrate } = await import("@jfrusher/knotwork"); +const { useKnotworkStore } = await import("@/lib/store/useKnotworkStore"); const { changeTeam } = await import("@/lib/money/edit"); const { brigadeDoc, useStore } = await import("./store"); @@ -14,14 +14,14 @@ const { brigadeDoc, useStore } = await import("./store"); * the one document, and every edit lands there at once, on the one history. */ type Raw = Record; -const example: Raw = JSON.parse(readFileSync(join(process.cwd(), "public", "fixtures", "example-wedding.trousseau.json"), "utf8")); +const example: Raw = JSON.parse(readFileSync(join(process.cwd(), "public", "fixtures", "example-wedding.knotwork.json"), "utf8")); const photographer = (example.crew.teams as Raw[]).find((team) => team.tag === "photographer")!.id as string; -const shared = () => useTrousseauStore.getState(); +const shared = () => useKnotworkStore.getState(); const storedTeams = () => (shared().raw as Raw).crew.teams as Raw[]; beforeEach(() => { - useTrousseauStore.setState({ status: "ready", raw: example, doc: migrate(example), past: [], future: [] }); + useKnotworkStore.setState({ status: "ready", raw: example, doc: migrate(example), past: [], future: [] }); useStore.setState({ selectedJobId: null }); }); diff --git a/suite/apps/brigade/state/sliceBridge.ts b/suite/apps/brigade/state/sliceBridge.ts index 116ae6aa..690a9968 100644 --- a/suite/apps/brigade/state/sliceBridge.ts +++ b/suite/apps/brigade/state/sliceBridge.ts @@ -1,4 +1,4 @@ -import type { Trousseau } from "@jfrusher/trousseau"; +import type { Knotwork } from "@jfrusher/knotwork"; import { personName, readCrew, readGuests } from "@/lib/model/slices"; import { parseDay } from "../core/import/day"; import { emptyDoc } from "../core/model/defaults"; @@ -25,7 +25,7 @@ import type { BrigadeDoc } from "../core/model/types"; */ /** The crew and the day as Brigade wants them, from the shared wedding. */ -export function readSlice(doc: Trousseau): BrigadeDoc { +export function readSlice(doc: Knotwork): BrigadeDoc { const crew = readCrew(doc); const base = emptyDoc(); diff --git a/suite/apps/brigade/state/store.test.ts b/suite/apps/brigade/state/store.test.ts index 5ebed66f..3c44fb62 100644 --- a/suite/apps/brigade/state/store.test.ts +++ b/suite/apps/brigade/state/store.test.ts @@ -2,20 +2,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("idb-keyval", () => ({ get: async () => undefined, set: async () => undefined, del: async () => undefined })); -const { emptyTrousseau, migrate } = await import("@jfrusher/trousseau"); -const { useTrousseauStore } = await import("@/lib/store/useTrousseauStore"); +const { emptyKnotwork, migrate } = await import("@jfrusher/knotwork"); +const { useKnotworkStore } = await import("@/lib/store/useKnotworkStore"); const { default: sampleDay } = await import("../fixtures/sample-day.day.json"); const { sampleDoc } = await import("../core/model/defaults"); const { crewSlice } = await import("./sliceBridge"); const { brigadeDoc, useStore } = await import("./store"); const state = () => useStore.getState(); -const doc = () => brigadeDoc(useTrousseauStore.getState().doc); +const doc = () => brigadeDoc(useKnotworkStore.getState().doc); describe("the store", () => { beforeEach(() => { - const raw = { ...emptyTrousseau(), crew: crewSlice(sampleDoc()), day: sampleDay } as Record; - useTrousseauStore.setState({ status: "ready", raw, doc: migrate(raw), past: [], future: [] }); + const raw = { ...emptyKnotwork(), crew: crewSlice(sampleDoc()), day: sampleDay } as Record; + useKnotworkStore.setState({ status: "ready", raw, doc: migrate(raw), past: [], future: [] }); }); it("puts a person on a job and takes them off again", () => { @@ -28,7 +28,7 @@ describe("the store", () => { it("undoes an assignment", () => { state().toggleAssignment("job-glasses", "per-joe"); - useTrousseauStore.getState().undo(); + useKnotworkStore.getState().undo(); expect(doc().jobs.find((job) => job.id === "job-glasses")?.personIds).toEqual([]); }); diff --git a/suite/apps/brigade/state/store.ts b/suite/apps/brigade/state/store.ts index 39adf35c..8c7c526b 100644 --- a/suite/apps/brigade/state/store.ts +++ b/suite/apps/brigade/state/store.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; -import type { Trousseau } from "@jfrusher/trousseau"; -import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; +import type { Knotwork } from "@jfrusher/knotwork"; +import { useKnotworkStore } from "@/lib/store/useKnotworkStore"; import { coverage, warningsByJob, type Warning } from "../core/jobs/coverage"; import { newId } from "../core/model/ids"; import type { BrigadeDoc, Job, Person, Team } from "../core/model/types"; @@ -11,7 +11,7 @@ export interface Cover { byJob: Map; } -let view: { doc: Trousseau; brigade: BrigadeDoc } | null = null; +let view: { doc: Knotwork; brigade: BrigadeDoc } | null = null; /** * The crew and the day as Brigade reads them, from the one wedding. @@ -20,7 +20,7 @@ let view: { doc: Trousseau; brigade: BrigadeDoc } | null = null; * stale view is impossible, and React sees the same object until something * changed. */ -export function brigadeDoc(doc: Trousseau): BrigadeDoc { +export function brigadeDoc(doc: Knotwork): BrigadeDoc { if (view?.doc === doc) return view.brigade; view = { doc, brigade: readSlice(doc) }; return view.brigade; @@ -42,8 +42,8 @@ export function coverFor(doc: BrigadeDoc): Cover { } /** What Delegation shows: the wedding as it is now, wherever it was last changed. */ -export const useBrigadeDoc = (): BrigadeDoc => useTrousseauStore((state) => brigadeDoc(state.doc)); -export const useCover = (): Cover => useTrousseauStore((state) => coverFor(brigadeDoc(state.doc))); +export const useBrigadeDoc = (): BrigadeDoc => useKnotworkStore((state) => brigadeDoc(state.doc)); +export const useCover = (): Cover => useKnotworkStore((state) => coverFor(brigadeDoc(state.doc))); /** Which jobs the board shows. */ export interface Filter { @@ -83,7 +83,7 @@ export interface StoreState { export const useStore = create((set, get) => { /** Shown as "Undo

Typical commercial wedding appTrousseauKnotwork