From 5b76177c8faeab383304ceda532f9e206f99a40d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:59:23 +0000 Subject: [PATCH 01/16] Spec: the toolbox, and travel, ceremony, boxes and bar The "Modular Workspace" PRD read against the suite, with the maintainer's answers recorded as decisions. Most of what it asked for exists: one document, local-first storage, lazy tools, and Timeline's double-booking check. What is new is a way to add and remove tools, travel time and calendar files on the Timeline, a Ceremony tool sharing Group shots' people, a Boxes tool tied to the day, and a UK-first bar calculator. Two findings shape it: a slice the contract does not list merges local-over-server with no conflict, so every new slice goes into SLICE_NAMES; and 144 KB of the 522 KB every page loads is fontkit, reached statically from the store. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HozwaQN87Fzg985EBm6MY1 --- docs/PRODUCT-ROADMAP.md | 1 + ...2026-09-29-toolbox-and-new-tools-design.md | 206 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-29-toolbox-and-new-tools-design.md diff --git a/docs/PRODUCT-ROADMAP.md b/docs/PRODUCT-ROADMAP.md index e0a1948a..f94bc42c 100644 --- a/docs/PRODUCT-ROADMAP.md +++ b/docs/PRODUCT-ROADMAP.md @@ -52,6 +52,7 @@ instead (see subsystem F). | M | Windows around the tools: Overview, Guests, Money, Checklist, Sync & history, palette | E, L | ⬜ planned — master plan, phase 2 | | N | Day-of binder and vendor links | E, J | ⬜ planned — master plan, phase 3 | | O | One live document: tools stop keeping copies; real-time sync | — | ⬜ planned — master plan, phase 4 | +| P | The toolbox, and travel, ceremony, boxes and bar | L, O | 🟡 **phase 0 built** — [spec](superpowers/specs/2026-09-29-toolbox-and-new-tools-design.md), [plan](superpowers/plans/2026-09-29-toolbox.md) 2026-09-29; phases 1–4 each get a plan first | ## Decisions log diff --git a/docs/superpowers/specs/2026-09-29-toolbox-and-new-tools-design.md b/docs/superpowers/specs/2026-09-29-toolbox-and-new-tools-design.md new file mode 100644 index 00000000..7bb209a2 --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-toolbox-and-new-tools-design.md @@ -0,0 +1,206 @@ +# Trousseau — the toolbox, and four new pieces: travel, ceremony, boxes, bar + +Date: 2026-09-29 +Status: direction approved by the maintainer (answers recorded below). Phase 0 +is built with this spec; every later phase gets its own dated plan first. +Scope: a way to add and remove tools, and four additions proposed by the +"Modular Workspace" PRD — a processional planner, a bar calculator, a packing +tool and a timeline collision checker. + +## Why + +The PRD proposed an opt-in workspace ("only the basics until you add more") +and four modules. It was read against the suite before anything was designed, +and a good part of it turned out to exist already. + +- **Reproduced** — shown in a test, a build or a real browser. +- **Traced** — every caller read; not run. + +| # | Finding | How established | +|---|---|---| +| T1 | The PRD's "shared data bus", local-first storage and lazy loading all exist: one document with one owner per slice, IndexedDB with no account needed, and every tool behind `next/dynamic`, with dialogs mounted on first open. | Traced | +| T2 | Module 4's "hard collision" is Timeline's `tag-double-booked`: the same supplier or party in two places at once. Buffers (`bufferMin`), slack and curfew are there too. **Missing:** travel time between locations, and a calendar file. There is no `.ics` anywhere. | Traced (`apps/cadence/core/schedule/conflicts.ts`); searched | +| T3 | Module 3's assignment exists — Checklist tasks have `personIds`, `dueOn` and `status`, and people are linked to guests. Packing as the maintainer describes it does not: boxes, what is in each, and where and when each must be. | Traced | +| T4 | Module 1's people exist: Group shots' cast (each partner, their parents, their wedding party, custom roles) mapped to guests. Grandparents, readers, ring bearer and flower party are not roles. | Traced (`lib/model/types.ts`) | +| T5 | A top-level key the contract does not list survives a sync, but is assembled local-over-server with no conflict (`mergeCloudDocument.ts`, lines 107 and 163), so a partner's edit to it is silently lost on the next push. **Every new slice goes into `SLICE_NAMES`.** | Traced | +| T6 | Every page loads the same 522 KB of gzipped JavaScript; a tool's own code adds 7 KB (Checklist) to 76 KB (Seating). 144 KB of the shared part is fontkit, reached statically from the store: `useTrousseauStore → documents/assets → portableAssets → plaque/syncAssets → plaque/sliceBridge → plaque/design → template/defaults → text/fit → text/measure`. The PRD's 100 KB per module is already met; the weight is underneath every module. | Reproduced — production build, each route loaded in Chromium and its JS summed; fontkit matched by its shaper tables against `node_modules/fontkit`; the chain traced by static imports | +| T7 | Ctrl/⌘ K is the command palette. | Traced | + +## Decisions + +The maintainer's answers, 2026-09-29. Where the answer delegated the choice, +the choice made is recorded with its reason. + +| # | Question | Decision | +|---|---|---| +| 1 | The PRD against the 2026-09-28 master plan | **The master plan wins.** The header, not a left sidebar; partners' names, not "bride"; one document, not micro-frontends. | +| 2 | What is opt-in | **The five stay, on by default, and can be removed** by someone who really wants to. New tools are added from the toolbox. | +| 3 | Where "which tools" lives | **In the wedding** — the partner and the planner see the same set. | +| 4 | The toolbox | **A slide-over at the right edge, with Add.** No keyboard shortcut of its own. | +| 5 | Public calculator pages for search | **Not intended.** Everything here is inside the planning app. | +| 6 | Module 4 | **Timeline features.** Travel times are typed by the couple: no lookup, no new `connect-src`. | +| 7 | Packing | **Its own tool**: boxes, what is in each, found easily, and each box tied to a part of the day — "this box has my shoes in it and needs to be at the house for 9". | +| 8 | Processional and group shots | **They share their people.** | +| 9 | Bar | **UK first, and every number adjustable.** | +| 10 | Tools feeding each other | Delegated: **proposed below**, each for the maintainer to accept on its own. | +| 11 | The wedding pack | **New printed pieces join it.** | +| 12 | Planners | **New tools' work can be kept in the library.** | +| 13 | Where the set is stored | Delegated: **a slice of its own, `tools`.** `event` is the wedding's who, where and when, and merges as one part: a list kept there would conflict whenever one partner changed the date while the other added a tool. A slice of its own merges on its own. | +| 14 | What removing a tool does | Delegated: **hides it, deletes nothing.** Its tab, its area on the front page, its entries in What is left, its tour chapter and its palette entry go. Its work is kept, the removal is one undo step, and adding it back brings it back as it was. Its page still opens from its address. | +| 15 | Where the shared people live | Delegated, for Phase 2: **a slice of their own, `cast`**, moved out of `shots` on load. A tool rewrites only its own slice; once two tools edit the cast, it is neither tool's. | + +## Phase 0 — the toolbox + +**Built 2026-09-29**, with [its plan](../plans/2026-09-29-toolbox.md). + +- **The `tools` slice** is `{ shown: string[] }`. With nothing stored, the five + are shown, which is every wedding today and every new one. Once somebody + adds or removes a tool the list is stored as written, and it keeps ids this + build does not know: a tool a newer version added is never removed by an + older one writing its own change. +- **The registry** (`lib/tools.ts`) gives each tool a stable `id`, which is + what is stored; an address could be renamed. `shownTools(doc)` gives the + shown tools in the registry's order, cached per document like every other + derived view. +- **The header** shows the shown tools and ends the row with **+**, which opens + **Tools** (`?panel=tools`): every tool with what it is for, and Add or + Remove. A removal says the work is kept, and is one step on the wedding's + history ("Undo removing Seating"). +- **Removed means out of sight everywhere at once**: the header, the front + page's areas, What is left (in `readiness` itself, so the planner's Weddings + page, which runs it on the server, agrees), "Take a tour", and the palette's + pages. Records in the palette — a table, a block — still open where they + live. + +## Phase 1 — Timeline: travel between places, and calendars + +- **Travel times** the couple types: `timeline.travel`, a list of + `{ between: [placeA, placeB], minutes }`, the same either way. The panel lists + the pairs the day actually moves between (from the blocks' locations), so + there is nothing to invent, only blanks to fill. +- **A new advisory, `no-travel-time`**: a lane or a tag that ends one block at + one place and starts its next somewhere else sooner than the typed time + allows — "Hair and make-up ends at 13:00 at the house; the first look starts + at 13:05 at the venue, 15 minutes away." A pair with no time typed is never + guessed at and never flagged. Advisory, because a person running is not a + print run that should stop. +- **Calendar files**: the day as `.ics`, whole or for one supplier (a tag's + blocks), written in the browser. Times go out in UTC from the wedding's own + `utcOffsetMin`, so a phone abroad shows the venue's clock correctly. + +## Phase 2 — one cast, and Ceremony + +- **`cast`** takes Group shots' cast and custom roles, moved on load by the + existing load-time pass. The fixed roles gain each partner's grandparents + (a party role, as the wedding party is). Readers, ring bearer, flower party + and the like are custom roles, as "Me and my family" already is. Group shots + reads it exactly as before. +- **Ceremony** (`/ceremony`, slice `ceremony`, added from the toolbox): the + processional as an ordered list of groups. Each group names who walks — + a role, a custom role, a guest, a family, a crew person (the officiant is + usually a supplier, not a guest) or free text — how they walk (alone, in + pairs, in threes), which side they go to (named after the partners), and a + cue: the music and the moment it changes. +- **"Suggest an order"** from the cast, as Group shots' `propose` does: a + starting point in the usual UK order, entirely editable. +- **Checks**: someone in the processional who has declined; a role nobody has + been cast in. Both through the cast's own resolver, as group shots do now. +- **Prints**: one page for the officiant and whoever runs the day, and plain + text to paste into an email. The page joins the wedding pack. + +## Phase 3 — Boxes + +The maintainer's words: boxes, what is in each, "in a good intuitive way", and +each box attached to a part of the day. + +```ts +interface Box { + id: string; + /** Printed large on its label: "3". */ + number: number; + name: string; // "Getting ready — Alex" + items: Item[]; + /** The part of the day it is needed for: where and when come from the block. */ + blockId: string | null; // null: not for the day — the honeymoon case + personIds: string[]; // who gets it there + notes: string; +} +interface Item { id: string; label: string; quantity: number; packed: boolean; notes: string } +``` + +- **Where and when are the block's.** "Needs to be at the house for 9" is a box + on the block "Getting ready", at the house, at 09:00; move the block and the + box moves with it. A drop-off that is not a block of its own is a moment + (a block with no length), which Timeline already has. One way to say where + and when, not a second one typed on the box. +- **Finding things**: one search over every box ("shoes" → Box 3), items moved + between boxes by dragging, and each box's packed count on its card. +- **Checks**: a box whose block is gone (as a job's can be); unpacked items + close to the day; a box nobody is taking. +- **Prints**: a label per box — its number, name, where and when, and what is + in it — and a packing list, as PDF and CSV. The list joins the wedding pack. +- **Starter boxes** are UK first: rings, the paperwork the ceremony needs, + supplier envelopes, the emergency kit, the guest book, and so on — a list to + edit, as "Add the usual tasks" is. + +## Phase 4 — Bar + +UK first, and every figure on screen, editable, and resettable to its default. + +- **Who is drinking**: the guests coming (`isComing`), live, less a share not + drinking. The count can be overridden, and says so while it is. +- **By part of the day**, because a UK wedding drinks in parts: the drinks + reception, the toast, the meal's wine, and the evening bar. Each part has its + own hours and per-head rate. +- **UK units**: wine and fizz in 75cl bottles, 125ml or 175ml glasses; beer in + 330ml bottles, 500ml cans, cases of 24; spirits in 70cl bottles, 25ml + measures; soft drinks and mixers in litres; ice in kilos. +- **Bar types**: full bar; beer and wine; signature cocktails with beer and + wine; low and no alcohol. +- **Adjusting**: a lighter or heavier crowd; prices per bottle or case, giving + an estimated spend; "we already have" per line; round up to whole cases for + sale or return, which UK merchants commonly offer. +- **The shopping list** grouped by where it is bought — supermarket, wine + merchant, cash and carry — editable. +- **The defaults are the product's credibility**, and are agreed with the + maintainer in Phase 4's plan before they are built. + +## Tools feeding each other — proposals + +None of these is built without the maintainer accepting it. Each keeps the +rule that a tool writes only its own slice and reads the others'. + +| From | To | What | +|---|---|---| +| Boxes | Delegation | A person taking a box sees "Box 3 to the house by 09:00" on their job sheet. Derived from the box, never stored as a job, so it follows the block. | +| Boxes | Binder | Find a box or an item on the day: "where are the rings?" | +| Ceremony | Timeline | The processional's cues shown inside the ceremony block, read-only. | +| Ceremony | Binder | The order of walking, on the phone, on the day. | +| Bar | Timeline | Reception, meal and evening hours read from the blocks the couple picks, rather than typed twice. | +| Bar | Money | The estimated spend shown against the budget as planned, not paid. | +| Bar | Checklist | "Buy the drinks" and "Collect the ice", dated back from the day. | +| Bar | Boxes | Crates as boxes, attached to the bar's block. | +| Timeline | Supplier links | Each supplier's calendar file on their own call sheet. | + +## Library + +A planner keeps, as the existing kinds are kept, without anything personal: +a **processional** as roles and formations with no guest named; a **set of +boxes** with their items and no people or blocks; **bar settings** — rates, +units and prices — with no guest count. + +## Open questions + +1. **T6**: take fontkit off every page by breaking the static chain above, and + add a CI check on each page's own JavaScript? And the limit, if so. +2. **S18** (from the master plan), since the PRD says "zero tracking": remove + Vercel Analytics, or say in the Privacy Policy what it counts? +3. **Cues on the day**: is the Ceremony → Timeline proposal wanted, or do the + cues stay in Ceremony? +4. **Order of phases**: proposed 1 Timeline (smallest, most already there), + 2 Ceremony, 3 Boxes, 4 Bar. + +## Explicitly deferred + +- Public calculator pages for search (decision 5). +- A keyboard shortcut for the toolbox (decision 4). From 564f3f7a763927eeab30dbeae6b4086fd19e5d1f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 09:59:23 +0000 Subject: [PATCH 02/16] Toolbox: add and remove tools, kept in the wedding The five stay on by default and any can be removed; the choice lives in a new contract slice, `tools`, so a partner and a planner see the same set and it merges as a part of its own rather than conflicting with the date. A removed tool is hidden, never deleted: its tab, its front-page area, What is left's items for it (in `readiness` itself, so the planner's Weddings page agrees), its tour chapter and its palette entry go, and adding it back brings its work back as it was. Add and Remove are one labelled step each on the wedding's history. The header ends the tabs with +, opening Tools as a slide-over at the right edge, addressable as ?panel=tools. Sync & history's addressing moved into one factory both panels use. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HozwaQN87Fzg985EBm6MY1 --- docs/superpowers/plans/2026-09-29-toolbox.md | 56 +++++++++++++++ src/envelope.test.ts | 3 +- src/envelope.ts | 4 +- src/index.ts | 2 + src/slices.ts | 7 ++ suite/components/shell/Header.tsx | 29 +++++++- suite/components/shell/ToolsPanel.tsx | 72 ++++++++++++++++++++ suite/components/shell/addressablePanel.ts | 40 +++++++++++ suite/components/shell/syncPanel.ts | 41 +---------- suite/components/shell/toolsPanel.ts | 4 ++ suite/e2e/a11y.spec.ts | 7 ++ suite/e2e/tools.spec.ts | 50 ++++++++++++++ suite/lib/documents/describe.test.ts | 5 ++ suite/lib/documents/describe.ts | 1 + suite/lib/documents/parts.ts | 2 +- suite/lib/model/overview.test.ts | 11 +++ suite/lib/model/overview.ts | 7 +- suite/lib/model/readiness.test.ts | 7 ++ suite/lib/model/readiness.ts | 7 +- suite/lib/model/selectors.test.ts | 4 ++ suite/lib/model/toolbox.test.ts | 52 ++++++++++++++ suite/lib/model/toolbox.ts | 51 ++++++++++++++ suite/lib/palette/search.test.ts | 7 ++ suite/lib/palette/search.ts | 5 +- suite/lib/tools.ts | 11 ++- suite/lib/tour/useTour.test.tsx | 17 ++++- suite/lib/tour/useTour.tsx | 9 ++- 27 files changed, 460 insertions(+), 51 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-29-toolbox.md create mode 100644 suite/components/shell/ToolsPanel.tsx create mode 100644 suite/components/shell/addressablePanel.ts create mode 100644 suite/components/shell/toolsPanel.ts create mode 100644 suite/e2e/tools.spec.ts create mode 100644 suite/lib/model/toolbox.test.ts create mode 100644 suite/lib/model/toolbox.ts diff --git a/docs/superpowers/plans/2026-09-29-toolbox.md b/docs/superpowers/plans/2026-09-29-toolbox.md new file mode 100644 index 00000000..a37d7220 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-toolbox.md @@ -0,0 +1,56 @@ +# The Toolbox Implementation Plan + +**Goal:** Let a wedding add and remove tools, the choice shared by everyone on +it: the five on by default, each removable, and new tools added from a +slide-over at the right edge. + +**Architecture:** One new slice, `tools` — `{ shown: string[] }` — in the +contract, so it syncs and merges as a part of its own. The registry gains ids; +one cached reader says which tools are shown; everything that listed the five +lists the shown ones instead. Nothing a removed tool made is touched. + +**Tech Stack:** TypeScript, Next.js (suite), Zustand, the kit's `SlideOver`. +**No new dependencies.** + +**Spec:** [docs/superpowers/specs/2026-09-29-toolbox-and-new-tools-design.md](../specs/2026-09-29-toolbox-and-new-tools-design.md) + +## Tasks + +- [x] **1. Contract.** `tools` joins `SLICE_NAMES` and `trousseauSchema` as a + `looseObject` slice, like `shots`. `envelope.test.ts` names it; + `preservation.test.ts` covers it through `SLICE_NAMES` unchanged. +- [x] **2. Registry and reader.** `Tool` gains `id`. `lib/model/toolbox.ts` + has `shownTools(doc)` (cached per document; the five when nothing is stored; + unknown ids ignored) and `withTool(raw, id, shown)`, which writes the list + keeping ids this build does not know. Tests: default, order, unknown ids + kept on write, referential stability in `selectors.test.ts`. +- [x] **3. Everything that listed the five.** The header's tabs; `overview`'s + areas; `readiness`'s items by where their fix is; the tour's walk; the + palette's pages. Tests on `overview`, `readiness` and `entries` with a tool + removed. +- [x] **4. The panel.** `components/shell/ToolsPanel.tsx` on `SlideOver`, + opened by the header's **+** and by `?panel=tools`; Add and Remove, each one + labelled step on the history. `describe.ts` names the part for Sync & + history. +- [x] **5. End to end.** Remove a tool: its tab goes, the change survives a + reload, undo brings it back. Axe on the open panel. + +## Status + +**Complete — all 5 tasks, 2026-09-29.** 1,752 suite tests and 102 in the +contract package, typecheck and build clean, all 85 Playwright tests green — +the 1024px header check included, with the **+** now in the row. No new +dependencies. + +**What executing it found.** The compiler found nothing to change when the +ninth slice arrived: nothing in the suite enumerated the slices except the +merge, and the merge was already right about a slice absent from a stored +document (no part, nothing invented). The work was entirely in the five places +that listed the tools' work: the header and the palette, which read `TOOLS`; +the front page's areas, What is left and the tour, each with its own list +keyed by the same ids, which is why a tool's id is its address. + +Sync & history's open state and the new panel's were the same thirty lines, so +the `?panel=` addressing moved into one factory, `addressablePanel`, which both +use. The store refuses a write before the wedding is read, so the panel's +buttons are off until then rather than silently doing nothing. diff --git a/src/envelope.test.ts b/src/envelope.test.ts index 2af0b322..b40154e7 100644 --- a/src/envelope.test.ts +++ b/src/envelope.test.ts @@ -25,7 +25,7 @@ describe("emptyTrousseau", () => { }); describe("SLICE_NAMES", () => { - it("lists exactly the eight publishable slices", () => { + it("lists exactly the nine publishable slices", () => { expect([...SLICE_NAMES]).toEqual([ "event", "guests", @@ -35,6 +35,7 @@ describe("SLICE_NAMES", () => { "stationery", "shots", "timeline", + "tools", ]); }); diff --git a/src/envelope.ts b/src/envelope.ts index e88f0712..c89d75e2 100644 --- a/src/envelope.ts +++ b/src/envelope.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { daySchema } from "./day.js"; import { eventSchema } from "./event.js"; -import { crewSchema, guestsSchema, seatingSchema, shotsSchema, stationerySchema, timelineSchema } from "./slices.js"; +import { crewSchema, guestsSchema, seatingSchema, shotsSchema, stationerySchema, timelineSchema, toolsSchema } from "./slices.js"; export const TROUSSEAU_KIND = "trousseau"; export const TROUSSEAU_VERSION = 1; @@ -20,6 +20,7 @@ export const SLICE_NAMES = [ "stationery", "shots", "timeline", + "tools", ] as const; export type SliceName = (typeof SLICE_NAMES)[number]; @@ -44,6 +45,7 @@ export const trousseauSchema = z.looseObject({ stationery: stationerySchema, shots: shotsSchema, timeline: timelineSchema, + tools: toolsSchema, /** Native documents, keyed by app name. Present only in an exported file. */ sources: z.record(z.string(), z.unknown()).default(() => ({})), }); diff --git a/src/index.ts b/src/index.ts index 65d136db..804390b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,12 +28,14 @@ export { shotsSchema, stationerySchema, timelineSchema, + toolsSchema, type Crew, type Guests, type Seating, type Shots, type Stationery, type TimelineSlice, + type Tools, } from "./slices.js"; export { TROUSSEAU_EXTENSION, diff --git a/src/slices.ts b/src/slices.ts index 6cdd1ea3..e579f981 100644 --- a/src/slices.ts +++ b/src/slices.ts @@ -21,6 +21,12 @@ export const crewSchema = z.looseObject({}).default(() => ({})); export const stationerySchema = z.looseObject({}).default(() => ({})); export const shotsSchema = z.looseObject({}).default(() => ({})); export const timelineSchema = z.looseObject({}).default(() => ({})); +/** + * Which tools the wedding shows. Owned by the launcher rather than any tool, + * as `event` is, and its own slice so that adding a tool never conflicts with + * a partner changing the date. + */ +export const toolsSchema = z.looseObject({}).default(() => ({})); export type Guests = z.infer; export type Seating = z.infer; @@ -28,3 +34,4 @@ export type Crew = z.infer; export type Stationery = z.infer; export type Shots = z.infer; export type TimelineSlice = z.infer; +export type Tools = z.infer; diff --git a/suite/components/shell/Header.tsx b/suite/components/shell/Header.tsx index 23e1b373..13f39561 100644 --- a/suite/components/shell/Header.tsx +++ b/suite/components/shell/Header.tsx @@ -4,9 +4,9 @@ import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Search } from "lucide-react"; +import { Plus, Search } from "lucide-react"; import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; -import { TOOLS } from "@/lib/tools"; +import { shownTools } from "@/lib/model/toolbox"; import { AccountStatus } from "./AccountStatus"; import { WeddingMenu } from "./WeddingMenu"; import { HowThisWorks } from "./TourButtons"; @@ -16,6 +16,7 @@ import { WhoIsHere } from "./WhoIsHere"; import { useDataPanel } from "./dataPanel"; import { useGuestImport } from "./guestImportPanel"; import { useSyncPanel } from "./syncPanel"; +import { useToolsPanel } from "./toolsPanel"; import { usePalette } from "./CommandPalette"; const DataManager = dynamic(() => import("./DataManager").then((m) => m.DataManager), { @@ -27,6 +28,9 @@ const GuestImport = dynamic(() => import("./GuestImport").then((m) => m.GuestImp const SyncHistory = dynamic(() => import("./SyncHistory").then((m) => m.SyncHistory), { ssr: false, }); +const ToolsPanel = dynamic(() => import("./ToolsPanel").then((m) => m.ToolsPanel), { + ssr: false, +}); const CommandPalette = dynamic(() => import("./CommandPalette").then((m) => m.CommandPalette), { ssr: false, }); @@ -63,6 +67,15 @@ export function Header() { const [syncEverOpened, setSyncEverOpened] = useState(false); if (syncOpen && !syncEverOpened) setSyncEverOpened(true); useEffect(() => useSyncPanel.getState().fromAddress(), []); + // Tools, adding and removing them, loaded the same way; `?panel=tools` opens it. + const toolsOpen = useToolsPanel((s) => s.open); + const showTools = useToolsPanel((s) => s.show); + const hideTools = useToolsPanel((s) => s.hide); + const [toolsEverOpened, setToolsEverOpened] = useState(false); + if (toolsOpen && !toolsEverOpened) setToolsEverOpened(true); + useEffect(() => useToolsPanel.getState().fromAddress(), []); + // The wedding's own choice of tools, the same for everyone planning it. + const tools = useTrousseauStore((s) => shownTools(s.doc)); // The palette: its button, and Ctrl/⌘ K from anywhere. const paletteOpen = usePalette((s) => s.open); const showPalette = usePalette((s) => s.show); @@ -96,7 +109,7 @@ export function Header() { {/* Scrolls within the header on a narrow screen, rather than making the whole page wider than it and pushing Data off the edge. */} {/* @@ -156,6 +178,7 @@ export function Header() { {dataEverOpened ? : null} {importEverOpened ? : null} {syncEverOpened ? : null} + {toolsEverOpened ? : null} {paletteEverOpened ? : null} ); diff --git a/suite/components/shell/ToolsPanel.tsx b/suite/components/shell/ToolsPanel.tsx new file mode 100644 index 00000000..0ccf5955 --- /dev/null +++ b/suite/components/shell/ToolsPanel.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { Plus, X } from "lucide-react"; +import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; +import { shownTools, withTool } from "@/lib/model/toolbox"; +import { TOOLS, type Tool } from "@/lib/tools"; +import { Button } from "@/components/ui/controls"; +import { SlideOver } from "@/components/ui/SlideOver"; + +/** + * The tools this wedding uses, each with what it is for, and Add or Remove. + * + * The choice is kept in the wedding, so whoever it is planned with sees the + * same tools. Removing one hides it and nothing more: what was made in it is + * kept, and is there again when it is added back. Either is one step on the + * wedding's history. + */ +export function ToolsPanel({ open, onClose }: { open: boolean; onClose: () => void }) { + const shown = useTrousseauStore((s) => shownTools(s.doc)); + // The store refuses a write before the wedding is read; say so by being off. + const ready = useTrousseauStore((s) => s.status === "ready"); + const setSlice = useTrousseauStore((s) => s.setSlice); + + const choose = (tool: Tool, show: boolean) => + setSlice("tools", withTool(useTrousseauStore.getState().raw, tool.id, show), { + label: `${show ? "adding" : "removing"} ${tool.name}`, + }); + + return ( + +
+
+

+ Tools +

+ +
+
+

The tools this wedding uses. Whoever you plan it with sees the same ones.

+
    + {TOOLS.map((tool) => { + const on = shown.includes(tool); + return ( +
  • + +
    +

    {tool.name}

    +

    {tool.tagline}

    +
    + {on ? ( + + ) : ( + + )} +
  • + ); + })} +
+

+ Removing a tool only hides it. Everything made in it is kept, and is there again when you add it back. +

+
+
+
+ ); +} diff --git a/suite/components/shell/addressablePanel.ts b/suite/components/shell/addressablePanel.ts new file mode 100644 index 00000000..486702df --- /dev/null +++ b/suite/components/shell/addressablePanel.ts @@ -0,0 +1,40 @@ +import { create } from "zustand"; + +/** + * Whether a slide-over is open. Addressable, as the design's slide-overs are: + * `?panel=` opens it, and opening it puts that in the address, so a link + * to it — or a reload — lands with it open. + */ +export interface AddressablePanel { + open: boolean; + show: () => void; + hide: () => void; + /** Open it if the address asks for it. Called once, as the page loads. */ + fromAddress: () => void; +} + +const PARAM = "panel"; + +export function addressablePanel(name: string) { + const address = (open: boolean) => { + const url = new URL(window.location.href); + if (open) url.searchParams.set(PARAM, name); + else url.searchParams.delete(PARAM); + window.history.replaceState(window.history.state, "", url); + }; + + return create()((set) => ({ + open: false, + show: () => { + address(true); + set({ open: true }); + }, + hide: () => { + address(false); + set({ open: false }); + }, + fromAddress: () => { + if (new URLSearchParams(window.location.search).get(PARAM) === name) set({ open: true }); + }, + })); +} diff --git a/suite/components/shell/syncPanel.ts b/suite/components/shell/syncPanel.ts index 0cc66f71..f238e588 100644 --- a/suite/components/shell/syncPanel.ts +++ b/suite/components/shell/syncPanel.ts @@ -1,39 +1,4 @@ -import { create } from "zustand"; +import { addressablePanel } from "./addressablePanel"; -/** - * Whether Sync & history is open. Addressable, as the design's slide-overs - * are: `?panel=sync` opens it, and opening it puts that in the address, so a - * link to it — or a reload — lands with it open. - */ -interface SyncPanel { - open: boolean; - show: () => void; - hide: () => void; - /** Open it if the address asks for it. Called once, as the page loads. */ - fromAddress: () => void; -} - -const PARAM = "panel"; -const VALUE = "sync"; - -function address(open: boolean) { - const url = new URL(window.location.href); - if (open) url.searchParams.set(PARAM, VALUE); - else url.searchParams.delete(PARAM); - window.history.replaceState(window.history.state, "", url); -} - -export const useSyncPanel = create()((set) => ({ - open: false, - show: () => { - address(true); - set({ open: true }); - }, - hide: () => { - address(false); - set({ open: false }); - }, - fromAddress: () => { - if (new URLSearchParams(window.location.search).get(PARAM) === VALUE) set({ open: true }); - }, -})); +/** Whether Sync & history is open: `?panel=sync`. */ +export const useSyncPanel = addressablePanel("sync"); diff --git a/suite/components/shell/toolsPanel.ts b/suite/components/shell/toolsPanel.ts new file mode 100644 index 00000000..56b04ade --- /dev/null +++ b/suite/components/shell/toolsPanel.ts @@ -0,0 +1,4 @@ +import { addressablePanel } from "./addressablePanel"; + +/** Whether Tools — adding and removing them — is open: `?panel=tools`. */ +export const useToolsPanel = addressablePanel("tools"); diff --git a/suite/e2e/a11y.spec.ts b/suite/e2e/a11y.spec.ts index 50d5139f..0282ad14 100644 --- a/suite/e2e/a11y.spec.ts +++ b/suite/e2e/a11y.spec.ts @@ -62,6 +62,13 @@ test("the Data panel, open, has no accessibility violations", async ({ page }) = await noViolations(page); }); +test("the Tools panel, open, has no accessibility violations", async ({ page }) => { + await seedExampleWedding(page); + await page.goto("/?panel=tools"); + await expect(page.getByRole("dialog", { name: "Tools" })).toBeVisible(); + await noViolations(page); +}); + test("the tour, open, has no accessibility violations", async ({ page }) => { await seedExampleWedding(page); await page.goto("/"); diff --git a/suite/e2e/tools.spec.ts b/suite/e2e/tools.spec.ts new file mode 100644 index 00000000..5b19cac7 --- /dev/null +++ b/suite/e2e/tools.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test"; +import { seedExampleWedding, storedDocument } from "./wedding"; + +/* + * Which tools a wedding shows is the wedding's own choice, kept in it: the + * five by default, any of them removable, and a removal only ever hides. + */ + +test("a tool removed from Tools leaves the header, and undo brings it back", async ({ page }) => { + await seedExampleWedding(page); + await page.goto("/checklist"); + const tabs = page.getByRole("navigation", { name: "Tools" }); + await expect(tabs.getByRole("link", { name: "Seating" })).toBeVisible(); + + await tabs.getByRole("button", { name: "Add or remove tools" }).click(); + const panel = page.getByRole("dialog", { name: "Tools" }); + await panel.getByRole("button", { name: "Remove Seating" }).click(); + await expect(panel.getByRole("button", { name: "Add Seating" })).toBeVisible(); + // Closed first: the header is inert behind the panel. + await page.keyboard.press("Escape"); + await expect(panel).toBeHidden(); + + await expect(tabs.getByRole("link", { name: "Seating" })).toHaveCount(0); + await expect(tabs.getByRole("link", { name: "Timeline" })).toBeVisible(); + + await page.getByRole("button", { name: "Undo removing Seating" }).click(); + await expect(tabs.getByRole("link", { name: "Seating" })).toBeVisible(); +}); + +test("a removal is kept in the wedding and survives a reload", async ({ page }) => { + await seedExampleWedding(page); + // Addressable, as every slide-over is. + await page.goto("/?panel=tools"); + const panel = page.getByRole("dialog", { name: "Tools" }); + await panel.getByRole("button", { name: "Remove Group shots" }).click(); + + // Stored before the reload is relied on: the write is what is being tested. + await expect + .poll(async () => (await storedDocument(page)).tools?.shown) + .toEqual(["seating", "place-cards", "timeline", "delegation"]); + await page.goto("/"); + + const tabs = page.getByRole("navigation", { name: "Tools" }); + await expect(tabs.getByRole("link", { name: "Delegation" })).toBeVisible(); + await expect(tabs.getByRole("link", { name: "Group shots" })).toHaveCount(0); + // Its area on the front page goes with it; the wedding's own pages stay. + const areas = page.getByRole("region", { name: "Where things stand" }); + await expect(areas.getByRole("link", { name: /Guests/ })).toBeVisible(); + await expect(areas.getByRole("link", { name: /Group shots/ })).toHaveCount(0); +}); diff --git a/suite/lib/documents/describe.test.ts b/suite/lib/documents/describe.test.ts index 34877edc..5316bf59 100644 --- a/suite/lib/documents/describe.test.ts +++ b/suite/lib/documents/describe.test.ts @@ -53,6 +53,11 @@ describe("what changed between two versions", () => { expect(describeChanges(before, after)).toEqual(["The day’s blocks put in a new order"]); }); + it("says when somebody added or removed a tool", () => { + const after = { ...before, tools: { shown: ["seating"] } }; + expect(describeChanges(before, after)).toEqual(["Which tools the wedding uses: changed"]); + }); + it("says nothing of a version that changed nothing", () => { expect(describeChanges(before, before)).toEqual([]); }); diff --git a/suite/lib/documents/describe.ts b/suite/lib/documents/describe.ts index fd80be4b..163bac13 100644 --- a/suite/lib/documents/describe.ts +++ b/suite/lib/documents/describe.ts @@ -21,6 +21,7 @@ const WHOLE: Partial> = { crew: "Suppliers and their people", shots: "The group shots", stationery: "The card design", + tools: "Which tools the wedding uses", }; const COLLECTION: Record = { diff --git a/suite/lib/documents/parts.ts b/suite/lib/documents/parts.ts index a3e6a808..c289f4b4 100644 --- a/suite/lib/documents/parts.ts +++ b/suite/lib/documents/parts.ts @@ -19,7 +19,7 @@ import { SLICE_NAMES, type SliceName } from "@jfrusher/trousseau"; * timeline/blocks/ one block * timeline/blocks#order the blocks' order * crew, crew/jobs/, crew/jobs#order - * event, shots, stationery + * event, shots, stationery, tools * * `day` is not a part: it is published from the timeline and the event, and * is worked out again rather than merged — see `mergeCloudDocument`. diff --git a/suite/lib/model/overview.test.ts b/suite/lib/model/overview.test.ts index 55f8c990..ce6b0327 100644 --- a/suite/lib/model/overview.test.ts +++ b/suite/lib/model/overview.test.ts @@ -31,6 +31,17 @@ describe("the front page's measures", () => { expect(areas.checklist).toMatchObject({ summary: "12 to do", detail: "7 done" }); }); + it("leaves out the areas of tools the wedding has removed, and keeps the wedding's own", () => { + const removed = { ...raw, tools: { shown: ["timeline", "delegation"] } }; + expect(overview(migrate(removed), removed).map((area) => area.id)).toEqual([ + "guests", + "money", + "checklist", + "timeline", + "delegation", + ]); + }); + it("says there is nothing yet, rather than that nothing is wrong, on an empty wedding", () => { const empty = migrate({}); for (const area of overview(empty, {})) { diff --git a/suite/lib/model/overview.ts b/suite/lib/model/overview.ts index a11de208..dfda7ac2 100644 --- a/suite/lib/model/overview.ts +++ b/suite/lib/model/overview.ts @@ -6,6 +6,7 @@ import { todayIso } from "@/lib/dates"; import { checklist } from "@/lib/checklist/checklist"; import { stationery } from "./readiness"; import { isComing, readCrew, readGuests, readSeating, readShots, resolvedDay } from "./slices"; +import { hiddenToolIds } from "./toolbox"; /** * How far along each part of the wedding is, for the front page. @@ -160,6 +161,10 @@ function groupShots(doc: Trousseau): Area { }; } +/** Every area, less those of the tools the wedding has removed. */ export function overview(doc: Trousseau, raw: unknown, today: string = todayIso()): Area[] { - return [guests(doc), costs(doc), tasks(doc, today), seating(doc), placeCards(doc, raw), timeline(doc), delegation(doc), groupShots(doc)]; + const hidden = hiddenToolIds(doc); + return [guests(doc), costs(doc), tasks(doc, today), seating(doc), placeCards(doc, raw), timeline(doc), delegation(doc), groupShots(doc)].filter( + (area) => !hidden.has(area.id), + ); } diff --git a/suite/lib/model/readiness.test.ts b/suite/lib/model/readiness.test.ts index 874162c4..ca747001 100644 --- a/suite/lib/model/readiness.test.ts +++ b/suite/lib/model/readiness.test.ts @@ -41,6 +41,13 @@ describe("what is left to do", () => { expect(ids({ guests: { g1: { id: "g1", firstName: "Charis" } } })).toEqual([]); }); + it("says nothing about a tool the wedding has removed", () => { + const guests = { ...GUESTS, g2: { id: "g2", firstName: "Tobias", rsvpStatus: "confirmed" } }; + const inSeating = (raw: Record) => wedding(raw).filter((item) => item.href === "/seating"); + expect(inSeating({ guests, ...TABLES })).not.toEqual([]); + expect(inSeating({ guests, ...TABLES, tools: { shown: ["timeline"] } })).toEqual([]); + }); + describe("place cards against the room", () => { const design = (extra: Record) => ({ guests: GUESTS, diff --git a/suite/lib/model/readiness.ts b/suite/lib/model/readiness.ts index fee4e3b2..81737e0b 100644 --- a/suite/lib/model/readiness.ts +++ b/suite/lib/model/readiness.ts @@ -1,5 +1,6 @@ import type { Trousseau } from "@jfrusher/trousseau"; import { guestName, isComing, readCrew, readGuests, readSeating, readShots, readTimeline } from "./slices"; +import { hiddenToolIds } from "./toolbox"; import { resolveShot } from "@/lib/ensemble/resolve"; import { DUE_SOON_DAYS, money } from "@/lib/money/money"; import { daysUntil, longDate, todayIso } from "@/lib/dates"; @@ -317,5 +318,9 @@ export function readiness(doc: Trousseau, raw: unknown, today: string = todayIso }); } - return out; + // A tool the wedding has removed is one it is not using: nothing in it is + // left to do. Here rather than in the page, so the planner's Weddings page, + // which runs this on the server, says the same. + const hidden = hiddenToolIds(doc); + return out.filter((item) => !hidden.has(item.href.slice(1))); } diff --git a/suite/lib/model/selectors.test.ts b/suite/lib/model/selectors.test.ts index a9451e45..4c54bb3e 100644 --- a/suite/lib/model/selectors.test.ts +++ b/suite/lib/model/selectors.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "vitest"; import { emptyTrousseau, migrate } from "@jfrusher/trousseau"; import { readCrew, readGuests, readSeating, readShots, readTimeline, resolvedDay, timelineDoc } from "./slices"; +import { hiddenToolIds, shownTools } from "./toolbox"; /** * Every slice reader must return the same object for the same document. @@ -20,6 +21,7 @@ const doc = migrate({ crew: { teams: [], people: [], jobs: [] }, shots: { cast: {}, sections: [{ id: "sec1", name: "Family", shots: [] }] }, stationery: { rowSource: "plan" }, + tools: { shown: ["seating", "timeline"] }, }); test.each([ @@ -30,6 +32,8 @@ test.each([ ["shots", () => readShots(doc)], ["timelineDoc", () => timelineDoc(doc)], ["resolved day", () => resolvedDay(doc)], + ["shown tools", () => shownTools(doc)], + ["hidden tools", () => hiddenToolIds(doc)], ])("reading %s twice returns the same object", (_name, read) => { expect(read()).toBe(read()); }); diff --git a/suite/lib/model/toolbox.test.ts b/suite/lib/model/toolbox.test.ts new file mode 100644 index 00000000..ac3750ec --- /dev/null +++ b/suite/lib/model/toolbox.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { emptyTrousseau, migrate } from "@jfrusher/trousseau"; +import { TOOLS } from "@/lib/tools"; +import { hiddenToolIds, shownTools, withTool } from "./toolbox"; + +const ids = (doc: Parameters[0]) => shownTools(doc).map((tool) => tool.id); + +describe("shownTools", () => { + it("shows the five when the wedding has never chosen", () => { + const doc = emptyTrousseau(); + expect(shownTools(doc)).toBe(TOOLS); + expect(hiddenToolIds(doc).size).toBe(0); + }); + + it("shows what is stored, in the registry's order rather than the stored one", () => { + const doc = migrate({ tools: { shown: ["group-shots", "seating"] } }); + expect(ids(doc)).toEqual(["seating", "group-shots"]); + expect([...hiddenToolIds(doc)]).toEqual(["place-cards", "timeline", "delegation"]); + }); + + it("shows nothing when everything has been removed", () => { + expect(ids(migrate({ tools: { shown: [] } }))).toEqual([]); + }); + + it("ignores an id this build does not know", () => { + expect(ids(migrate({ tools: { shown: ["bar", "timeline"] } }))).toEqual(["timeline"]); + }); + + it("every tool's address is its id", () => { + for (const tool of TOOLS) expect(tool.href).toBe(`/${tool.id}`); + }); +}); + +describe("withTool", () => { + it("removing from a wedding that never chose keeps the other four", () => { + expect(withTool({}, "seating", false)).toEqual({ + shown: ["place-cards", "timeline", "delegation", "group-shots"], + }); + }); + + it("adding back puts it in the list once", () => { + const removed = { tools: withTool({}, "seating", false) }; + const added = withTool(removed, "seating", true); + expect(added["shown"]).toEqual(["place-cards", "timeline", "delegation", "group-shots", "seating"]); + expect(withTool({ tools: added }, "seating", true)["shown"]).toEqual(added["shown"]); + }); + + it("keeps an id this build does not know, and the slice's other keys", () => { + const raw = { tools: { shown: ["bar", "seating", "timeline"], note: "from a newer build" } }; + expect(withTool(raw, "seating", false)).toEqual({ shown: ["bar", "timeline"], note: "from a newer build" }); + }); +}); diff --git a/suite/lib/model/toolbox.ts b/suite/lib/model/toolbox.ts new file mode 100644 index 00000000..468dcd0e --- /dev/null +++ b/suite/lib/model/toolbox.ts @@ -0,0 +1,51 @@ +import type { Trousseau } from "@jfrusher/trousseau"; +import { TOOLS, type Tool, type ToolId } from "@/lib/tools"; +import { cached } from "./slices"; + +/** + * Which tools the wedding shows — the same for both partners and the planner, + * because it is kept in the wedding, in the `tools` slice. + * + * Nothing stored means the five, which is every wedding until somebody adds or + * removes one. Removing a tool only hides it: what was made in it stays in its + * own slice, untouched, and is there again when it is added back. + */ + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** The ids as stored, from a parsed or a raw document, or null when the wedding has never chosen. */ +function storedIds(doc: unknown): string[] | null { + const slice = isRecord(doc) ? doc["tools"] : null; + const shown = isRecord(slice) ? slice["shown"] : null; + return Array.isArray(shown) ? shown.filter((id): id is string => typeof id === "string") : null; +} + +/** The tools the wedding shows, in the registry's order. */ +export function shownTools(doc: Trousseau): readonly Tool[] { + return cached(doc, "shownTools", () => { + const ids = storedIds(doc); + return ids === null ? TOOLS : TOOLS.filter((tool) => ids.includes(tool.id)); + }); +} + +/** The ids of the tools the wedding has removed, for everything that lists a tool's work. */ +export function hiddenToolIds(doc: Trousseau): ReadonlySet { + return cached(doc, "hiddenToolIds", () => { + const shown = shownTools(doc); + return new Set(TOOLS.filter((tool) => !shown.includes(tool)).map((tool) => tool.id)); + }); +} + +/** + * The `tools` slice with one tool added or taken away. + * + * Built from the stored document rather than the registry, so an id this build + * does not know — a tool a newer version added — is kept, and an older build + * removing Seating never removes the newer one's tool with it. + */ +export function withTool(raw: unknown, id: ToolId, show: boolean): Record { + const slice = isRecord(raw) && isRecord(raw["tools"]) ? raw["tools"] : {}; + const others = (storedIds(raw) ?? TOOLS.map((tool) => tool.id)).filter((stored) => stored !== id); + return { ...slice, shown: show ? [...others, id] : others }; +} diff --git a/suite/lib/palette/search.test.ts b/suite/lib/palette/search.test.ts index 3b323b80..2ef86387 100644 --- a/suite/lib/palette/search.test.ts +++ b/suite/lib/palette/search.test.ts @@ -24,6 +24,13 @@ describe("finding anything by name", () => { ]); }); + it("does not offer a tool the wedding has removed", () => { + const pages = entries(migrate({ ...raw, tools: { shown: ["seating"] } })) + .filter((entry) => entry.kind === "Page") + .map((entry) => entry.name); + expect(pages).toEqual(["Overview", "Guests", "Money", "Checklist", "Binder", "Seating"]); + }); + it("finds a guest and says where they sit, opening the list found to them by id", () => { const [zainab] = search(all, "zainab thist"); expect(zainab).toMatchObject({ kind: "Guest", name: "Zainab Thistlewood", detail: "No table yet" }); diff --git a/suite/lib/palette/search.ts b/suite/lib/palette/search.ts index 7679429c..43b97ccf 100644 --- a/suite/lib/palette/search.ts +++ b/suite/lib/palette/search.ts @@ -2,7 +2,8 @@ import type { Trousseau } from "@jfrusher/trousseau"; import { formatClock } from "@/apps/cadence/core/time/minutes"; import { guestName, readCrew, readGuests, readSeating, readTimeline, resolvedDay } from "@/lib/model/slices"; import { longDate } from "@/lib/dates"; -import { TOOLS, WEDDING_PAGES } from "@/lib/tools"; +import { WEDDING_PAGES } from "@/lib/tools"; +import { shownTools } from "@/lib/model/toolbox"; /** * Everything in the wedding that can be gone to by name — a page, a guest, a @@ -30,7 +31,7 @@ export function entries(doc: Trousseau): Entry[] { const people = new Map(crew.people.map((person) => [person.id, person.name])); return [ - ...[...WEDDING_PAGES, ...TOOLS].map((page) => ({ kind: "Page" as const, name: page.name, detail: "", href: page.href })), + ...[...WEDDING_PAGES, ...shownTools(doc)].map((page) => ({ kind: "Page" as const, name: page.name, detail: "", href: page.href })), ...Object.values(guests).map((guest) => { const name = guestName(guest); const table = guest.assignedTableId ? tables[guest.assignedTableId]?.label : ""; diff --git a/suite/lib/tools.ts b/suite/lib/tools.ts index 31960619..d59e2b60 100644 --- a/suite/lib/tools.ts +++ b/suite/lib/tools.ts @@ -1,8 +1,12 @@ import { Armchair, Banknote, Camera, ClipboardList, Clock, Contact, LayoutDashboard, ListChecks, Smartphone, Users, type LucideIcon } from "lucide-react"; +/** What a wedding stores to say it shows a tool. Never renamed: it is data. */ +export type ToolId = "seating" | "place-cards" | "timeline" | "delegation" | "group-shots"; + /** The five tools, in the order the day is planned in. Nav and landing share this. */ export interface Tool { - href: "/seating" | "/place-cards" | "/timeline" | "/delegation" | "/group-shots"; + id: ToolId; + href: `/${ToolId}`; name: string; tagline: string; icon: LucideIcon; @@ -19,6 +23,7 @@ export interface Tool { export const TOOLS: readonly Tool[] = [ { + id: "seating", href: "/seating", tokens: "tableaux-tokens", name: "Seating", @@ -26,6 +31,7 @@ export const TOOLS: readonly Tool[] = [ icon: Armchair, }, { + id: "place-cards", href: "/place-cards", tokens: "plaque-tokens", name: "Place cards", @@ -33,6 +39,7 @@ export const TOOLS: readonly Tool[] = [ icon: Contact, }, { + id: "timeline", href: "/timeline", tokens: "cadence-tokens", name: "Timeline", @@ -40,6 +47,7 @@ export const TOOLS: readonly Tool[] = [ icon: Clock, }, { + id: "delegation", href: "/delegation", tokens: "brigade-tokens", name: "Delegation", @@ -47,6 +55,7 @@ export const TOOLS: readonly Tool[] = [ icon: ClipboardList, }, { + id: "group-shots", href: "/group-shots", tokens: "ensemble-tokens", name: "Group shots", diff --git a/suite/lib/tour/useTour.test.tsx b/suite/lib/tour/useTour.test.tsx index 9868e9d3..fe71f4e3 100644 --- a/suite/lib/tour/useTour.test.tsx +++ b/suite/lib/tour/useTour.test.tsx @@ -9,8 +9,13 @@ const { TourProvider, useTour } = await import("./useTour"); const { CHAPTERS } = await import("./steps"); const { ConfirmProvider } = await import("@/components/ui/Confirm"); const { TakeTheTour, HowThisWorks } = await import("@/components/shell/TourButtons"); +const { useTrousseauStore } = await import("@/lib/store/useTrousseauStore"); +const { migrate } = await import("@jfrusher/trousseau"); -afterEach(() => cleanup()); +afterEach(() => { + cleanup(); + useTrousseauStore.setState({ doc: migrate({}) }); +}); /** What the tour card would show, without the card's modal dialog. */ function Probe() { @@ -52,6 +57,16 @@ test("Take a tour runs every chapter, one after another", async () => { expect(screen.getByTestId("at").textContent).toBe("closed"); }); +test("Take a tour leaves out the chapters of tools the wedding has removed", async () => { + useTrousseauStore.setState({ doc: migrate({ tools: { shown: ["timeline"] } }) }); + const kept = CHAPTERS.filter((chapter) => ["shell", "guests", "timeline"].includes(chapter.id)); + const total = kept.reduce((sum, chapter) => sum + chapter.steps.length, 0); + renderTour(); + // "Take the tour again" by now: the test before this one has seen it. + await act(async () => fireEvent.click(screen.getByRole("button", { name: /Take (a|the) tour/ }))); + expect(screen.getByTestId("at").textContent).toBe(`/ 1/${total}`); +}); + test("How this page works explains the page you are on, and only that", () => { renderTour(); fireEvent.click(screen.getByRole("button", { name: "How this page works" })); diff --git a/suite/lib/tour/useTour.tsx b/suite/lib/tour/useTour.tsx index 5ea3118f..4d827159 100644 --- a/suite/lib/tour/useTour.tsx +++ b/suite/lib/tour/useTour.tsx @@ -2,6 +2,8 @@ import { createContext, useCallback, useContext, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; +import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; +import { hiddenToolIds } from "@/lib/model/toolbox"; import { CHAPTERS, type ChapterId, type TourChapter, type TourStep } from "./steps"; /** @@ -87,7 +89,12 @@ export function TourProvider({ children }: { children: React.ReactNode }) { (id: ChapterId) => begin(walkOf(CHAPTERS.filter((chapter) => chapter.id === id))), [begin], ); - const startAll = useCallback(() => begin(walkOf(CHAPTERS)), [begin]); + // Every chapter but those of the tools the wedding has removed. + const hidden = useTrousseauStore((s) => hiddenToolIds(s.doc)); + const startAll = useCallback( + () => begin(walkOf(CHAPTERS.filter((chapter) => !hidden.has(chapter.id)))), + [begin, hidden], + ); const next = useCallback(() => { if (open) go({ walk: open.walk, index: open.index + 1 }); From 1b3b00cbdc13f03de8ee0cf523aafcfd7ddbb1a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 10:37:24 +0000 Subject: [PATCH 03/16] Guests is the first tab; Money, Checklist and Binder join the toolbox The header now reads Guests, then the wedding's tools, then +. Guests is never removable: every tool is built on the guest list. Money, Checklist and Binder are tools like the five, off until a wedding adds them, and the menu under the wedding's name keeps only Overview. A tool not shown, removed or never added, is out of the header, the front page, What is left, the tour and the palette alike. With every tool added, the header at 1024px gave the tabs 392px and left Timeline 244 of the 323 its controls need, putting four out of sight. The tool on screen now keeps its controls and the tabs scroll instead, which holds as more tools arrive. The example wedding shows every tool, held there by a test, so the tour and front page can still point at all of it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HozwaQN87Fzg985EBm6MY1 --- docs/superpowers/plans/2026-09-29-toolbox.md | 15 ++++ .../specs/2026-09-28-expansion-master-plan.md | 6 ++ ...2026-09-29-toolbox-and-new-tools-design.md | 33 +++++-- suite/components/shell/Header.tsx | 13 ++- suite/components/shell/Overview.tsx | 13 +-- suite/e2e/header.spec.ts | 3 +- suite/e2e/money.spec.ts | 3 +- suite/e2e/tools.spec.ts | 19 ++++- suite/lib/documents/live.ts | 4 +- suite/lib/model/overview.test.ts | 12 +-- suite/lib/model/readiness.test.ts | 20 ++++- suite/lib/model/toolbox.test.ts | 15 +++- suite/lib/model/toolbox.ts | 16 ++-- suite/lib/palette/search.test.ts | 8 +- suite/lib/palette/search.ts | 4 +- suite/lib/tools.ts | 85 +++++++++++++++---- suite/lib/tour/exampleWedding.fixture.test.ts | 6 ++ .../fixtures/example-wedding.trousseau.json | 12 +++ 18 files changed, 216 insertions(+), 71 deletions(-) diff --git a/docs/superpowers/plans/2026-09-29-toolbox.md b/docs/superpowers/plans/2026-09-29-toolbox.md index a37d7220..fe42d6c5 100644 --- a/docs/superpowers/plans/2026-09-29-toolbox.md +++ b/docs/superpowers/plans/2026-09-29-toolbox.md @@ -54,3 +54,18 @@ Sync & history's open state and the new panel's were the same thirty lines, so the `?panel=` addressing moved into one factory, `addressablePanel`, which both use. The store refuses a write before the wedding is read, so the panel's buttons are off until then rather than silently doing nothing. + +**Second round, the same day** (spec decisions 16–19). Guests became the +header's first tab and never leaves it; Money, Checklist and Binder became +tools, off until added, and the menu under the wedding's name kept only +Overview. `Tool` gained `defaultOn`. Four readiness tests and the fixture's +front-page counts fell silent at once, which was the change working: a +wedding without Money is not told about balances. They now show every tool +explicitly, beside a test that pins the silence. + +Two things the gate found. The header test at 1024px, with the example +wedding showing all eight tools, put four of Timeline's controls out of +sight: measured, the tabs took 392px and left the controls 244 of 323. The +controls now never shrink below what the tool put there, and the tabs scroll +instead. And Binder, now a tool, has no header of its own to test, being a +page for a phone. diff --git a/docs/superpowers/specs/2026-09-28-expansion-master-plan.md b/docs/superpowers/specs/2026-09-28-expansion-master-plan.md index 0d672caf..93aeaa2d 100644 --- a/docs/superpowers/specs/2026-09-28-expansion-master-plan.md +++ b/docs/superpowers/specs/2026-09-28-expansion-master-plan.md @@ -169,6 +169,12 @@ thing on a screen is the thing that needs a decision. controls (Timeline's zoom, Fit day and Present) do not fit and scroll out of sight inside it. + **Superseded 2026-09-29** by the + [toolbox spec](2026-09-29-toolbox-and-new-tools-design.md) (decisions 17–19): + Guests is the header's first tab, Money, Checklist and Binder are tools a + wedding adds from the toolbox, and only Overview stays under the wedding's + name. + ## Signing in safely The rule: **nothing is replaced without a restorable copy, and nothing is diff --git a/docs/superpowers/specs/2026-09-29-toolbox-and-new-tools-design.md b/docs/superpowers/specs/2026-09-29-toolbox-and-new-tools-design.md index 7bb209a2..e88aebf6 100644 --- a/docs/superpowers/specs/2026-09-29-toolbox-and-new-tools-design.md +++ b/docs/superpowers/specs/2026-09-29-toolbox-and-new-tools-design.md @@ -49,12 +49,22 @@ the choice made is recorded with its reason. | 14 | What removing a tool does | Delegated: **hides it, deletes nothing.** Its tab, its area on the front page, its entries in What is left, its tour chapter and its palette entry go. Its work is kept, the removal is one undo step, and adding it back brings it back as it was. Its page still opens from its address. | | 15 | Where the shared people live | Delegated, for Phase 2: **a slice of their own, `cast`**, moved out of `shots` on load. A tool rewrites only its own slice; once two tools edit the cast, it is neither tool's. | +The maintainer's second answers, the same day, after Phase 0 was built: + +| # | Question | Decision | +|---|---|---| +| 16 | Boxes as designed in Phase 3 | **Yes**: each box on a block of the day, its where and when the block's. | +| 17 | Guests | **A tab of its own at the top**, first. Delegated: **never removable**, because every tool is built on the guest list, so it is not in the toolbox. | +| 18 | Money, Checklist, Binder | **In the toolbox**, as tools. Delegated: **off until added** — the words were "in the tool box" against "a tab on the top", and a header of Guests and the five is the clean start the PRD asked for. This replaces the master plan's rule that the wedding's own pages sit under its name; only Overview is left there. | +| 19 | Room in the header | Delegated: **the tool on screen keeps its controls; the tabs give way and scroll.** Measured at 1024px with every tool added: the tabs took 392px and left Timeline's controls 244 of the 323 they need. Denser tabs would have bought about 90px, enough today and not once Phase 1–4 add tools. | + ## Phase 0 — the toolbox **Built 2026-09-29**, with [its plan](../plans/2026-09-29-toolbox.md). -- **The `tools` slice** is `{ shown: string[] }`. With nothing stored, the five - are shown, which is every wedding today and every new one. Once somebody +- **The `tools` slice** is `{ shown: string[] }`. With nothing stored, the tools + marked `defaultOn` are shown — the five — which is every wedding today and + every new one; Money, Checklist and Binder wait in the toolbox. Once somebody adds or removes a tool the list is stored as written, and it keeps ids this build does not know: a tool a newer version added is never removed by an older one writing its own change. @@ -62,15 +72,19 @@ the choice made is recorded with its reason. what is stored; an address could be renamed. `shownTools(doc)` gives the shown tools in the registry's order, cached per document like every other derived view. -- **The header** shows the shown tools and ends the row with **+**, which opens +- **The header** shows Guests, always first, then the shown tools, and ends the + row with **+**, which opens **Tools** (`?panel=tools`): every tool with what it is for, and Add or Remove. A removal says the work is kept, and is one step on the wedding's history ("Undo removing Seating"). -- **Removed means out of sight everywhere at once**: the header, the front - page's areas, What is left (in `readiness` itself, so the planner's Weddings - page, which runs it on the server, agrees), "Take a tour", and the palette's - pages. Records in the palette — a table, a block — still open where they - live. +- **Not shown means out of sight everywhere at once**, whether removed or + never added: the header, the front page's areas, What is left (in + `readiness` itself, so the planner's Weddings page, which runs it on the + server, agrees), "Take a tour", and the palette's pages. Records in the + palette — a table, a block — still open where they live. So a wedding that + has not added Money hears nothing of balances falling due. +- **The example wedding shows every tool**, held there by a test: it exists to + show what Trousseau does, and the tour and front page point at all of it. ## Phase 1 — Timeline: travel between places, and calendars @@ -111,7 +125,8 @@ the choice made is recorded with its reason. ## Phase 3 — Boxes The maintainer's words: boxes, what is in each, "in a good intuitive way", and -each box attached to a part of the day. +each box attached to a part of the day. The design below was confirmed as it +stands (decision 16). ```ts interface Box { diff --git a/suite/components/shell/Header.tsx b/suite/components/shell/Header.tsx index 13f39561..8e8bb541 100644 --- a/suite/components/shell/Header.tsx +++ b/suite/components/shell/Header.tsx @@ -7,6 +7,7 @@ import { usePathname } from "next/navigation"; import { Plus, Search } from "lucide-react"; import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; import { shownTools } from "@/lib/model/toolbox"; +import { GUESTS } from "@/lib/tools"; import { AccountStatus } from "./AccountStatus"; import { WeddingMenu } from "./WeddingMenu"; import { HowThisWorks } from "./TourButtons"; @@ -74,7 +75,8 @@ export function Header() { const [toolsEverOpened, setToolsEverOpened] = useState(false); if (toolsOpen && !toolsEverOpened) setToolsEverOpened(true); useEffect(() => useToolsPanel.getState().fromAddress(), []); - // The wedding's own choice of tools, the same for everyone planning it. + // The guest list, then the wedding's own choice of tools, the same for + // everyone planning it. const tools = useTrousseauStore((s) => shownTools(s.doc)); // The palette: its button, and Ctrl/⌘ K from anywhere. const paletteOpen = usePalette((s) => s.open); @@ -109,7 +111,7 @@ export function Header() { {/* Scrolls within the header on a narrow screen, rather than making the whole page wider than it and pushing Data off the edge. */}