From 2616ccecf19abf9441ea46fc19bb03bbe19c6ff6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 21:13:29 +0000 Subject: [PATCH 01/20] =?UTF-8?q?Spec:=20seating=20stationery=20suite=20?= =?UTF-8?q?=E2=80=94=20live=20room,=20seat=20tokens,=20table=20and=20room-?= =?UTF-8?q?sized=20boards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CeAntAq32mr3MdpB3Jci6U --- .../2026-10-04-seating-stationery-design.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-04-seating-stationery-design.md diff --git a/docs/superpowers/specs/2026-10-04-seating-stationery-design.md b/docs/superpowers/specs/2026-10-04-seating-stationery-design.md new file mode 100644 index 00000000..ac54e5bc --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-seating-stationery-design.md @@ -0,0 +1,158 @@ +# Place cards — the seating stationery suite: live from the room, table-sized to room-sized + +Date: 2026-10-04 +Status: direction approved by the maintainer (answers recorded below). Nothing +is built with this spec; each phase is built and reviewed on its own. +Scope: keep Place cards token-driven, and extend it from guest-sized cards to +table cards and room-sized seating boards that stay in step with Seating. + +## Why + +Place cards prints one design, from a copy of the room taken when someone +pressed a button, on A4 or Letter. A wedding needs place cards, escort cards, +table cards and a seating board at once, all saying the same thing as the room +says now. + +- **Traced** — every caller read; not run. + +| # | Finding | How established | +|---|---|---| +| F1 | The room reaches the cards by copy. *Use the room* calls `setCsv(rowsFromRoom())` (`apps/plaque/ui/panels/DataPanel.tsx:80`), which writes the rows into `stationery.rows`. A guest moved in Seating afterwards keeps the old table on their card until the button is pressed again. | Traced | +| F2 | The room's columns are First Name, Last Name, Name, Table, Dietary, Side (`apps/plaque/state/fromRoom.ts:24`). No seat. | Traced | +| F3 | Seat order is stored: `table.assignedGuestIds[i]` is seat *i* (`apps/tableaux/components/canvas/TableNode.tsx:479`). Whether a table numbers its seats is `table.seatMode === "seat"`, and the guest link already derives `seat = index + 1` from it (`lib/share/snapshot.ts:63-69`). | Traced | +| F4 | Paper is A4 or Letter only (`apps/plaque/core/units.ts:15`), and a card preset may not exceed the largest page (`core/data/cardPresets.ts:52`). Nothing room-sized can be made. | Traced | +| F5 | The `stationery` slice holds exactly one `Design` (`apps/plaque/state/design.ts`, `sliceBridge.ts`). Changing from place cards to table numbers replaces the place cards. | Traced | +| F6 | Seating prints separately: a to-scale floor-plan PDF and two fixed card layouts in jsPDF (`apps/tableaux/utils/exportPdf.ts`, `cardTemplates.ts`, `components/layout/PrintModal.tsx`). It shares no fonts, tokens or design with Place cards, so the two can disagree. | Traced | +| F7 | Rows already have stable ids, and per-row overrides hang off the id (`core/data/artefacts.ts`, `rowId`). Using guest ids as row ids lets a hand-tweaked card follow its guest through a move. | Traced | +| F8 | The guest link carries its decryption key in the fragment (`lib/share/guestLink.ts:23`), but what it decrypts is an allow-list of name, table and seat (`lib/share/snapshot.ts`) — the same facts a printed board shows. A QR code of it on a public board discloses nothing the board does not. | Traced | +| F9 | `document` scope makes exactly one artefact, so a list longer than one page cannot spill onto a second (`core/data/artefacts.ts`, the "ponytail" note). The alphabetical finder needs that spill. | Traced | + +## Decisions + +The maintainer's answers, 2026-10-04. + +| # | Question | Decision | +|---|---|---| +| 1 | Which boards | **All four:** table-list board, floor-plan map, alphabetical finder, per-table card. | +| 2 | Large output | **Both:** a full-size single-page PDF for a print shop, and the same tiled across A4/Letter with overlap marks. | +| 3 | Room link | **Always live.** No *Use the room* button; rows are read from the room. Overrides keyed by guest id. | +| 4 | Several pieces | **A stationery suite:** several named pieces in the slice, shared fonts, colours and images. Existing design migrated in. | +| 5 | Seating's own print | **Retired.** Seating's Print opens Place cards on the matching piece. Its geometry is reused, not duplicated. | +| 6 | Seat numbers | **Per table, as stored**, and only where the table's `seatMode` is `"seat"`. One derivation shared with the guest link. | +| 7 | Floor-plan styling | **Styled by properties** on one `room` element, which fits itself to its box. | +| 8 | Delivery | **Phased; one commit per phase, with tests; reviewed between phases.** | +| 9 | After a print, the room changes | **Reprint just the changed:** what changed, per piece, and a PDF of only those cards. Boards reprint whole. | +| 10 | Finder order | **Surname, with letter headings;** sort is a setting. | +| 11 | Starters | **All four:** escort card with seat, table-list board, finder and floor plan, per-table card. | +| 12 | Out of scope | **Nothing.** QR codes are in. | + +## The model + +### Tokens + +The room's columns grow; they are still just columns, so the template binding, +overrides and both renderers learn nothing new. + +| Token | Value | +|---|---| +| `{{Seat}}` | `"7"`, or empty where the table's `seatMode` is not `"seat"` | +| `{{Table Number}}` | Table position by label order, `"4"` (so a named table can still carry a number) | +| `{{Table Size}}` | Seated count at the guest's table | +| `{{Initial}}` | First letter of the surname, upper-case — what the finder groups by | +| `{{Guest Link}}` | The guest-link URL, empty when none is published — what a `qr` element binds to | + +The seat lookup moves out of `lib/share/snapshot.ts` into `lib/model` as one +function both callers use. + +### Pieces + +```ts +interface Stationery { + pieces: Record; + order: PieceId[]; + activePieceId: PieceId; + /** Shared across pieces — a font uploaded once is everywhere. */ + uploadedIcons, assetNames +} +interface Piece { + id, name; + source: { kind: "room" } | { kind: "csv"; headers; rows; rowIds; fileName }; + card: CardSpec; sheet: SheetSpec; template: Template; + printed: PrintRecord | null; // decision 9 +} +``` + +`source: room` stores no rows. Rows are derived per render from the document +through the per-document cache in `lib/model/slices.ts` (selectors must not +allocate). CSV import stays, as a piece's other source, for anyone not using +Seating. + +A stored single design migrates to one piece called "Place cards", with its +current source (`fileName === "the room"` → `room`). The migration is a +`persist.ts` version bump with a test against a stored v-current fixture. + +### Paper + +`PageSizeName` gains `A3 | A2 | A1 | A0 | POSTER_18x24 | POSTER_24x36`, plus +`CUSTOM` with width and height. A piece whose card is the page (one artefact per +page, no imposition) exports two ways: + +- **Print shop** — one page at full size, with bleed and crop marks. +- **Tile** — the same page cut into A4/Letter tiles with a 10 mm overlap, tile + labels ("B2"), and alignment marks. This is a step in `paginate`, after + imposition, so every renderer gets it for free. + +The card-preset size cap follows the new largest page. + +### New elements + +- **`grid`** — repeats a sub-template once per group (by default, per table), + flowing into columns inside its box: table-list boards, per-table cards' + guest list. Shrink-to-fit applies to the whole grid, so one long table cannot + leave the others unreadable. +- **`list` spill** — `document` scope may span several pages, by column then by + page, with `{{Initial}}` headings. This retires the F9 limitation. +- **`room`** — the floor plan from Seating's geometry (`seatPositions`, + `floorPlanSvg`'s placement), drawn as resolved rects, lines and text so both + renderers draw it with no new drawing code. Properties: font, name colour, + table fill and stroke, seat style, table labels on or off, seat labels + (first name / full name / seat number / none), zones and walls on or off, + optional `focusTable` for a single table's mini diagram. +- **`qr`** — encodes a token (default `{{Guest Link}}`) as vector modules, so + it prints sharp at any size. + +### Reprints + +`printed` records, per artefact key, a fingerprint of the row values it printed +from. On open, the piece lists changed artefacts ("Alex Ng: Table 3 → 7") and +offers *Export only these*. Boards (one artefact) say "changed since printed" +and reprint whole. + +## Phases + +Each phase is one commit, with tests, reviewed before the next. + +1. **Suite.** Pieces in the stationery slice, piece switcher, migration of the + single design. No behaviour change otherwise. +2. **Live room.** `source: room`, derived rows, guest-id row ids, the new + tokens and the shared seat lookup. *Use the room* removed. +3. **Big paper.** New page sizes, print-shop export and tiling. +4. **Table boards.** The `grid` element; table-list board and per-table card + starters. +5. **Finder.** List spill, letter headings, surname sort; finder starter. +6. **Floor plan.** The `room` element, reusing Seating's geometry; floor-plan + starter and per-table mini map. +7. **QR, reprints, escort starter.** The `qr` element, `printed` records and + *Export only these*; the escort-card-with-seat starter. +8. **Retire Seating's print.** Its Print button opens Place cards on the right + piece; `exportPdf.ts` card paths and `cardTemplates.ts` removed, floor-plan + geometry kept where the `room` element uses it. + +## Open questions, to settle at the start of their phase + +- Phase 4: what a grid does with an unseated guest — a "Still to seat" block, + or left out with a warning. (Today a place card prints with an empty table.) +- Phase 6: whether a `room` element can show only part of the room (one + marquee of two), or always the whole of it. +- Phase 3: CMYK or print-shop colour profiles are **not** planned; the PDF is + RGB, as now. Say so if a print shop needs otherwise. From 3898d81ebf1e94558ca86a2fee266ac05b5979f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 21:40:29 +0000 Subject: [PATCH 02/20] Place cards: a stationery suite of named pieces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stationery slice held one design, so making table numbers meant overwriting the place cards. It now holds an ordered list of pieces, each a whole design of its own, with uploaded assets' names, icons and two workspace preferences shared between them (slice version 3). - apps/plaque/state/suite.ts: the stored shape, import-free, so readiness, the overview, content and the library read it without loading Place cards. A v1/v2 single design is read as the one piece it always was, under the id and name every wedding's first piece gets. - persist: each piece is validated on its own; one that cannot be read is left out and named, rather than discarding the whole suite. - The editor still works on a flat Design — the open piece plus what is shared — so no panel changed. Which piece is open is the window's, not the wedding's: undo never switches pieces. - PiecesBar: tabs, new, rename (double-click), duplicate, remove. - Readiness: "cards from a file" now ignores a piece with no rows, which every new empty piece would otherwise trip; it and the dietary check look across every piece. - Library: "cards" keeps and applies the whole suite, keeping each piece's row scope (a per-table board is broken without it). Designs kept before pieces still apply. - The example wedding is stored in the new shape. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CeAntAq32mr3MdpB3Jci6U --- suite/apps/plaque/App.tsx | 4 +- suite/apps/plaque/state/design.ts | 66 + suite/apps/plaque/state/live.test.ts | 75 +- suite/apps/plaque/state/persist.test.ts | 74 +- suite/apps/plaque/state/persist.ts | 97 +- suite/apps/plaque/state/sliceBridge.ts | 45 +- suite/apps/plaque/state/store.ts | 111 +- suite/apps/plaque/state/suite.ts | 43 + suite/apps/plaque/state/syncAssets.ts | 4 +- suite/apps/plaque/ui/PiecesBar.module.css | 68 + suite/apps/plaque/ui/PiecesBar.tsx | 90 + suite/e2e/place-cards.spec.ts | 28 +- suite/lib/library/items.test.ts | 36 +- suite/lib/library/items.ts | 49 +- suite/lib/model/content.test.ts | 11 + suite/lib/model/content.ts | 4 +- suite/lib/model/overview.ts | 8 +- suite/lib/model/readiness.test.ts | 11 + suite/lib/model/readiness.ts | 33 +- suite/lib/tour/exampleWedding.fixture.test.ts | 7 +- .../fixtures/example-wedding.knotwork.json | 2288 +++++++++-------- 21 files changed, 1909 insertions(+), 1243 deletions(-) create mode 100644 suite/apps/plaque/state/suite.ts create mode 100644 suite/apps/plaque/ui/PiecesBar.module.css create mode 100644 suite/apps/plaque/ui/PiecesBar.tsx diff --git a/suite/apps/plaque/App.tsx b/suite/apps/plaque/App.tsx index f53f0b7d..10da6c01 100644 --- a/suite/apps/plaque/App.tsx +++ b/suite/apps/plaque/App.tsx @@ -31,6 +31,7 @@ import { ExportBar } from "./ui/ExportBar"; import { MissingAssets } from "./ui/MissingAssets"; import { Pagination } from "./ui/Pagination"; import { PersistenceBar } from "./ui/PersistenceBar"; +import { PiecesBar } from "./ui/PiecesBar"; import { RowsDrawer } from "./ui/RowsDrawer"; import { Sidebar } from "./ui/Sidebar"; import { WarningsList } from "./ui/WarningsList"; @@ -275,10 +276,11 @@ export function App() {
+ {saveError && ( writeDesign(designOf(usePlaque.getState()), { silent: true })} + onRetry={() => writeDesign(designOf(usePlaque.getState()), usePlaque.getState().pieceId, { silent: true })} /> )} diff --git a/suite/apps/plaque/state/design.ts b/suite/apps/plaque/state/design.ts index b21004ab..960fc8d7 100644 --- a/suite/apps/plaque/state/design.ts +++ b/suite/apps/plaque/state/design.ts @@ -1,6 +1,7 @@ import type { RowIssue, GuestRow } from "../core/data/rows"; import { defaultCard, defaultSheet } from "../core/template/defaults"; import type { CardSpec, SheetSpec, Template } from "../core/types"; +import { FIRST_PIECE } from "./suite"; /** * What of Place cards is the wedding's: the stationery slice. Everything @@ -72,3 +73,68 @@ export function initialDesign(): Design { export function designOf(source: Design): Design { return Object.fromEntries(DESIGN_KEYS.map((key) => [key, source[key]])) as unknown as Design; } + +/** What each piece has of its own. Everything else in a `Design` is shared by the suite. */ +export const PIECE_KEYS = [ + "card", + "sheet", + "template", + "headers", + "rows", + "rowIds", + "merged", + "csvIssues", + "fileName", +] as const satisfies readonly (keyof Design)[]; + +export type PieceDesign = Pick; +export type SharedDesign = Omit; + +/** One printed thing — place cards, table numbers, the seating board. */ +export interface Piece extends PieceDesign { + id: string; + name: string; +} + +/** The stationery slice: the pieces in order, and what they share. */ +export interface Suite extends SharedDesign { + pieces: Piece[]; +} + +/** A wedding's stationery before anything is made: one empty set of place cards. */ +export function initialSuite(): Suite { + const { pieces: _none, ...shared } = splitDesign(initialDesign()); + return { ...shared, pieces: [newPiece(FIRST_PIECE.id, FIRST_PIECE.name)] }; +} + +/** An empty piece. */ +export function newPiece(id: string, name: string): Piece { + return { id, name, ...splitDesign(initialDesign()).pieces[0]! }; +} + +/** The flat view the editor works on: one piece, with what every piece shares. */ +export function designFor(suite: Suite, pieceId: string): Design { + const piece = suite.pieces.find((p) => p.id === pieceId); + if (!piece) throw new Error(`No piece "${pieceId}" in the stationery.`); + const { pieces: _pieces, ...shared } = suite; + const { id: _id, name: _name, ...own } = piece; + return { ...shared, ...own }; +} + +/** The suite with `design` written back as piece `pieceId`, and as what the pieces share. */ +export function withDesign(suite: Suite, pieceId: string, design: Design): Suite { + if (!suite.pieces.some((p) => p.id === pieceId)) throw new Error(`No piece "${pieceId}" in the stationery.`); + const { pieces: [own], ...shared } = splitDesign(design); + return { + ...shared, + pieces: suite.pieces.map((p) => (p.id === pieceId ? { id: p.id, name: p.name, ...own! } : p)), + }; +} + +function splitDesign(design: Design): { pieces: [PieceDesign] } & SharedDesign { + const own = Object.fromEntries(PIECE_KEYS.map((key) => [key, design[key]])) as unknown as PieceDesign; + const shared = Object.fromEntries( + DESIGN_KEYS.filter((key) => !(PIECE_KEYS as readonly string[]).includes(key)).map((key) => [key, design[key]]), + ) as unknown as SharedDesign; + return { ...shared, pieces: [own] }; +} diff --git a/suite/apps/plaque/state/live.test.ts b/suite/apps/plaque/state/live.test.ts index 3da0763d..1fb1b686 100644 --- a/suite/apps/plaque/state/live.test.ts +++ b/suite/apps/plaque/state/live.test.ts @@ -21,6 +21,8 @@ const ROWS = [ const shared = () => useKnotworkStore.getState(); const plaque = () => usePlaque.getState(); const stored = () => (shared().raw as Raw).stationery as Raw; +/** The piece on screen, as the wedding holds it. */ +const piece = () => (stored().pieces as Raw[]).find((p) => p.id === plaque().pieceId)!; beforeEach(() => { const raw = emptyKnotwork() as unknown as Raw; @@ -30,21 +32,25 @@ beforeEach(() => { test("an edit is in the wedding the moment it is made", () => { plaque().setBackground("#fdfbf7"); - expect(stored().template.backgroundHex).toBe("#fdfbf7"); + expect(piece().template.backgroundHex).toBe("#fdfbf7"); }); test("a change to the stationery from elsewhere is what the editor shows", () => { - shared().setSlice("stationery", { ...stored(), template: { ...stored().template, backgroundHex: "#112233" } }, { label: "a design from the library" }); + shared().setSlice( + "stationery", + { ...stored(), pieces: [{ ...piece(), template: { ...piece().template, backgroundHex: "#112233" } }] }, + { label: "a design from the library" }, + ); expect(plaque().template.backgroundHex).toBe("#112233"); }); test("the header's undo takes a Place cards edit back, on the wedding's one history", () => { plaque().addElement("rect"); expect(shared().past.at(-1)?.label).toBe("adding to the card"); - const count = stored().template.elements.length; + const count = piece().template.elements.length; shared().undo(); - expect(stored().template.elements.length).toBe(count - 1); + expect(piece().template.elements.length).toBe(count - 1); expect(plaque().template.elements.length).toBe(count - 1); }); @@ -80,10 +86,67 @@ test("a selected element undone out of existence is no longer selected", () => { }); test("a design the wedding holds that cannot be read is said so, and the next edit starts fresh", () => { - shared().setSlice("stationery", { ...stored(), card: { ...stored().card, widthMm: "85" } }, { silent: true }); + shared().setSlice( + "stationery", + { ...stored(), pieces: [{ ...piece(), card: { ...piece().card, widthMm: "85" } }] }, + { silent: true }, + ); expect(plaque().designProblem).toMatch(/widthMm.*Starting fresh/); plaque().setBackground("#fdfbf7"); expect(plaque().designProblem).toBeNull(); - expect(stored().card.widthMm).toBe(85); + expect(piece().card.widthMm).toBe(85); +}); + +test("a new piece opens empty, and editing it leaves the place cards as they were", () => { + const placeCards = plaque().template; + plaque().addPiece("Table numbers"); + expect(plaque().pieces.map((p) => p.name)).toEqual(["Place cards", "Table numbers"]); + expect(plaque().template.elements).toEqual([]); + + plaque().setBackground("#223344"); + expect(piece().name).toBe("Table numbers"); + plaque().switchPiece("place-cards"); + expect(plaque().template).toEqual(placeCards); +}); + +test("pieces share their uploaded assets' names", () => { + plaque().noteAssetName("user:mono", "Monogram.png"); + plaque().addPiece("Menus"); + expect(plaque().assetNames["user:mono"]).toBe("Monogram.png"); + expect(stored().assetNames).toEqual({ "user:mono": "Monogram.png" }); +}); + +test("a copied piece keeps the design and the list, under its own name", () => { + plaque().duplicatePiece("place-cards"); + expect(plaque().pieces.map((p) => p.name)).toEqual(["Place cards", "Place cards (copy)"]); + expect(plaque().rows).toEqual(ROWS); + expect(plaque().pieceId).not.toBe("place-cards"); +}); + +test("undoing a new piece takes it away and shows the first", () => { + plaque().addPiece("Escort cards"); + shared().undo(); + expect(plaque().pieces.map((p) => p.name)).toEqual(["Place cards"]); + expect(plaque().pieceId).toBe("place-cards"); +}); + +test("removing the piece on screen shows its neighbour, and the last piece cannot go", () => { + plaque().addPiece("Board"); + plaque().removePiece(plaque().pieceId); + expect(plaque().pieces.map((p) => p.name)).toEqual(["Place cards"]); + expect(plaque().pieceId).toBe("place-cards"); + expect(() => plaque().removePiece("place-cards")).toThrow(/last piece/); +}); + +test("a wedding saved with one design opens it as its place cards, and the next edit keeps it", () => { + const { pieces, ...sharedParts } = stored(); + const [{ id: _id, name: _name, ...flat }] = pieces as Raw[]; + shared().setSlice("stationery", { ...sharedParts, ...flat, version: 2 }, { silent: true }); + expect(plaque().pieces).toEqual([{ id: "place-cards", name: "Place cards" }]); + expect(plaque().rows).toEqual(ROWS); + + plaque().setBackground("#fdfbf7"); + expect(stored().version).toBe(3); + expect(piece().rows).toEqual(ROWS); }); diff --git a/suite/apps/plaque/state/persist.test.ts b/suite/apps/plaque/state/persist.test.ts index 2720e0cf..f66a0dd8 100644 --- a/suite/apps/plaque/state/persist.test.ts +++ b/suite/apps/plaque/state/persist.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { defaultCard, defaultSheet, defaultTemplate } from "../core/template/defaults"; -import { load, type Persisted } from "./persist"; +import { load } from "./persist"; +import { FIRST_PIECE } from "./suite"; const design = () => ({ card: defaultCard(), @@ -8,7 +9,8 @@ const design = () => ({ template: defaultTemplate(["First Name", "Last Name"]), }); -const good = (over: Partial = {}): Persisted => ({ +/** A save from before pieces: one flat design. Every one of these must still open. */ +const good = (over: Record = {}) => ({ version: 2, savedAt: "2026-08-17T13:42:00.000Z", ...design(), @@ -33,11 +35,16 @@ describe("load", () => { expect(load({})).toEqual({ status: "empty" }); }); - it("accepts a well-formed save", () => { + it("opens a single-design save as the one piece it always was", () => { const result = load(good()); expect(result.status).toBe("ok"); - expect(result.status === "ok" && result.data.rows).toHaveLength(1); - expect(result.status === "ok" && result.data.savedAt).toBe("2026-08-17T13:42:00.000Z"); + if (result.status !== "ok") return; + expect(result.data.version).toBe(3); + expect(result.data.savedAt).toBe("2026-08-17T13:42:00.000Z"); + expect(result.data.pieces).toHaveLength(1); + expect(result.data.pieces[0]).toMatchObject({ ...FIRST_PIECE, fileName: "guests.csv" }); + expect(result.data.pieces[0]!.rows).toHaveLength(1); + expect(result.problem).toBeNull(); }); it("discards a save from another version rather than half-applying it", () => { @@ -88,14 +95,15 @@ describe("load", () => { it("reads a design saved when the undo history travelled with it, and leaves the history behind", () => { const result = load({ ...good(), past: [design()], future: [] }); expect(result.status).toBe("ok"); - expect(result.status === "ok" && Object.keys(result.data).sort()).toEqual(Object.keys(good()).sort()); + expect(result.status === "ok" && Object.keys(result.data)).not.toContain("past"); + expect(result.status === "ok" && Object.keys(result.data.pieces[0]!)).not.toContain("past"); }); it("gives rows positional ids when the save predates them", () => { // Overrides keyed by those same positional ids still land after an upgrade. const { rowIds: _rowIds, ...older } = good(); const result = load(older); - expect(result.status === "ok" && result.data.rowIds).toEqual(["r0"]); + expect(result.status === "ok" && result.data.pieces[0]!.rowIds).toEqual(["r0"]); }); it("upgrades a v1 save, which had no timestamp", () => { @@ -103,8 +111,56 @@ describe("load", () => { const result = load(v1); expect(result.status).toBe("ok"); if (result.status !== "ok") return; - expect(result.data.version).toBe(2); + expect(result.data.version).toBe(3); expect(result.data.savedAt).toBeNull(); - expect(result.data.rows).toHaveLength(1); + expect(result.data.pieces[0]!.rows).toHaveLength(1); + }); +}); + +describe("load, with pieces", () => { + const piece = (id: string, name: string, over: Record = {}) => { + const { version: _v, savedAt: _s, uploadedIcons: _u, assetNames: _a, snapEnabled: _n, sheetCollapsed: _c, ...own } = good(); + return { id, name, ...own, ...over }; + }; + const suite = (pieces: unknown[]) => ({ + version: 3, + savedAt: null, + pieces, + uploadedIcons: { "user:leaf": "M0 0" }, + assetNames: { "user:x": "Monogram.png" }, + snapEnabled: false, + sheetCollapsed: true, + }); + + it("reads every piece in order, with what they share", () => { + const result = load(suite([piece("a", "Place cards"), piece("b", "Table numbers")])); + expect(result.status).toBe("ok"); + if (result.status !== "ok") return; + expect(result.data.pieces.map((p) => p.name)).toEqual(["Place cards", "Table numbers"]); + expect(result.data.assetNames).toEqual({ "user:x": "Monogram.png" }); + expect(result.data.uploadedIcons).toEqual({ "user:leaf": "M0 0" }); + expect(result.data.snapEnabled).toBe(false); + expect(result.data.sheetCollapsed).toBe(true); + }); + + it("leaves out a piece it cannot read, keeps the rest, and names the one it lost", () => { + const broken = piece("b", "Table numbers", { card: { ...good().card, widthMm: "100" } }); + const result = load(suite([piece("a", "Place cards"), broken])); + expect(result.status).toBe("ok"); + if (result.status !== "ok") return; + expect(result.data.pieces.map((p) => p.id)).toEqual(["a"]); + expect(result.problem).toMatch(/widthMm.*"Table numbers" was left out/); + }); + + it("leaves out a second piece under an id already read", () => { + const result = load(suite([piece("a", "Place cards"), piece("a", "Escort cards")])); + expect(result.status === "ok" && result.data.pieces.map((p) => p.name)).toEqual(["Place cards"]); + expect(result.status === "ok" && result.problem).toMatch(/Escort cards/); + }); + + it("discards a suite with no readable piece", () => { + expect(load(suite([]))).toMatchObject({ status: "discarded" }); + expect(load(suite([piece("", "Nameless")]))).toMatchObject({ status: "discarded" }); + expect(load({ ...suite([]), pieces: "nope" })).toMatchObject({ status: "discarded" }); }); }); diff --git a/suite/apps/plaque/state/persist.ts b/suite/apps/plaque/state/persist.ts index a0291410..edc3c438 100644 --- a/suite/apps/plaque/state/persist.ts +++ b/suite/apps/plaque/state/persist.ts @@ -1,16 +1,17 @@ -import { designOf, type Design } from "./design"; +import type { Piece, Suite } from "./design"; +import { SUITE_VERSION, isRecord, storedPieces } from "./suite"; -export const VERSION = 2; +export const VERSION = SUITE_VERSION; /** - * The stationery slice as stored: the design, versioned and stamped. It - * travels wherever the wedding does: this device, and the account when the + * The stationery slice as stored: the suite of pieces, versioned and stamped. + * It travels wherever the wedding does: this device, and the account when the * wedding is synced to one. * * Uploaded font and image binaries are NOT here. Those are separate keys in * IndexedDB — see blobStore. */ -export interface Persisted extends Design { +export interface Persisted extends Suite { version: number; /** ISO time of the write. */ savedAt: string | null; @@ -18,13 +19,15 @@ export interface Persisted extends Design { export type LoadResult = | { status: "empty" } - | { status: "ok"; data: Persisted } + /** `problem` names any piece that could not be read and was left out. */ + | { status: "ok"; data: Persisted; problem: string | null } | { status: "discarded"; reason: string }; /** - * Reads the stationery slice. Anything unreadable is discarded rather than - * partially applied: a half-loaded template would put the user in a state - * they cannot reason about or undo. + * Reads the stationery slice. A piece that cannot be read is left out whole + * rather than partially applied — a half-loaded template would put the user in + * a state they cannot reason about or undo — and named, so its loss is never + * silent. With no readable piece at all, the slice is discarded. * * A slice with no version was written by something other than Place cards — * an empty envelope, most likely — and is treated as nothing saved. @@ -35,39 +38,71 @@ export function load(slice: unknown): LoadResult { if (!("version" in slice)) return { status: "empty" }; const version = slice["version"]; - if (version !== 1 && version !== VERSION) { + if (version !== 1 && version !== 2 && version !== VERSION) { return { status: "discarded", reason: "The saved design was made by a different version of Plaque.", }; } + if (version === VERSION && !Array.isArray(slice["pieces"])) { + return { status: "discarded", reason: "The saved design was incomplete." }; + } - const bad = firstBadDesignField(slice); - if (bad) return { status: "discarded", reason: bad }; - - if (!Array.isArray(slice["rows"]) || !Array.isArray(slice["headers"])) { - return { status: "discarded", reason: "The saved guest list could not be read." }; + const pieces: Piece[] = []; + const problems: string[] = []; + for (const stored of storedPieces(slice)) { + const read = readPiece(stored, pieces); + if (typeof read === "string") problems.push(read); + else pieces.push(read); + } + if (pieces.length === 0) { + return { status: "discarded", reason: problems[0] ?? "The saved design was incomplete." }; } - const data = slice as unknown as Persisted; return { status: "ok", data: { version: VERSION, - savedAt: typeof data.savedAt === "string" ? data.savedAt : null, - ...designOf({ - ...data, - // Absent in anything written before the sheet pane could be collapsed. - sheetCollapsed: slice["sheetCollapsed"] === true, - assetNames: isRecord(slice["assetNames"]) ? (slice["assetNames"] as Record) : {}, - // Absent in anything written before per-row editing. Positional ids match - // what buildArtefacts falls back to, so overrides keyed by them still land. - rowIds: Array.isArray(slice["rowIds"]) - ? (slice["rowIds"] as string[]) - : (data.rows ?? []).map((_, i) => `r${i}`), - merged: isRecord(slice["merged"]) ? (slice["merged"] as Persisted["merged"]) : {}, - }), + savedAt: typeof slice["savedAt"] === "string" ? slice["savedAt"] : null, + pieces, + uploadedIcons: isRecord(slice["uploadedIcons"]) ? (slice["uploadedIcons"] as Record) : {}, + assetNames: isRecord(slice["assetNames"]) ? (slice["assetNames"] as Record) : {}, + snapEnabled: slice["snapEnabled"] !== false, + // Absent in anything written before the sheet pane could be collapsed. + sheetCollapsed: slice["sheetCollapsed"] === true, }, + problem: problems.length > 0 ? problems.join(" ") : null, + }; +} + +/** One piece, or why it cannot be read. `before` is what has been read already. */ +function readPiece(source: Record, before: Piece[]): Piece | string { + const id = source["id"]; + const name = typeof source["name"] === "string" && source["name"] ? source["name"] : "A piece"; + if (typeof id !== "string" || !id) return `"${name}" had no id, so it was left out.`; + if (before.some((p) => p.id === id)) return `"${name}" was saved twice, so the second was left out.`; + + const bad = firstBadDesignField(source); + if (bad) return `${bad} "${name}" was left out.`; + if (!Array.isArray(source["rows"]) || !Array.isArray(source["headers"])) { + return `The saved guest list for "${name}" could not be read, so it was left out.`; + } + + const rows = source["rows"] as Piece["rows"]; + return { + id, + name, + card: source["card"] as Piece["card"], + sheet: source["sheet"] as Piece["sheet"], + template: source["template"] as Piece["template"], + headers: source["headers"] as string[], + rows, + // Absent in anything written before per-row editing. Positional ids match + // what buildArtefacts falls back to, so overrides keyed by them still land. + rowIds: Array.isArray(source["rowIds"]) ? (source["rowIds"] as string[]) : rows.map((_, i) => `r${i}`), + merged: isRecord(source["merged"]) ? (source["merged"] as Piece["merged"]) : {}, + csvIssues: Array.isArray(source["csvIssues"]) ? (source["csvIssues"] as Piece["csvIssues"]) : [], + fileName: typeof source["fileName"] === "string" ? source["fileName"] : null, }; } @@ -110,7 +145,3 @@ function firstBadNumber(source: Record, keys: readonly string[] } return null; } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/suite/apps/plaque/state/sliceBridge.ts b/suite/apps/plaque/state/sliceBridge.ts index c1c65dcf..e1b63cca 100644 --- a/suite/apps/plaque/state/sliceBridge.ts +++ b/suite/apps/plaque/state/sliceBridge.ts @@ -1,27 +1,48 @@ import { useKnotworkStore, type WriteOptions } from "@/lib/store/useKnotworkStore"; -import { initialDesign, type Design } from "./design"; +import { designFor, initialSuite, withDesign, type Design, type Suite } from "./design"; import { load, VERSION } from "./persist"; /** - * Where Place cards' design lives: the `stationery` slice of the shared - * wedding, so it travels with the backup, the sync and everything else. + * Where Place cards' designs live: the `stationery` slice of the shared + * wedding, so they travel with the backup, the sync and everything else. * - * Plaque keeps no copy of it. Its store shows what this slice holds, and every - * design edit is written here first, on the wedding's one history. + * Plaque keeps no copy of them. Its store shows one piece of this slice, and + * every design edit is written here first, on the wedding's one history. */ -/** The design the wedding holds — the starting one when it has none — and why, when it could not be read. */ -export function readDesign(raw: Record): { design: Design; problem: string | null } { +/** The suite the wedding holds — the starting one when it has none — and why, when some or all of it could not be read. */ +export function readSuite(raw: Record): { suite: Suite; problem: string | null } { const result = load(raw["stationery"]); if (result.status === "ok") { - const { version: _version, savedAt: _savedAt, ...design } = result.data; - return { design, problem: null }; + const { version: _version, savedAt: _savedAt, ...suite } = result.data; + return { suite, problem: result.problem }; } - return { design: initialDesign(), problem: result.status === "discarded" ? `${result.reason} Starting fresh.` : null }; + return { suite: initialSuite(), problem: result.status === "discarded" ? `${result.reason} Starting fresh.` : null }; } -export function writeDesign(design: Design, options: WriteOptions): void { +export function writeSuite(suite: Suite, options: WriteOptions): void { useKnotworkStore .getState() - .setSlice("stationery", { version: VERSION, savedAt: new Date().toISOString(), ...design }, options); + .setSlice("stationery", { version: VERSION, savedAt: new Date().toISOString(), ...suite }, options); +} + +/** `pieceId` when the suite still has it, otherwise its first piece. */ +export function resolvePieceId(suite: Suite, pieceId: string | null): string { + return suite.pieces.some((p) => p.id === pieceId) ? pieceId! : suite.pieces[0]!.id; +} + +/** One piece of the wedding's stationery as the editor sees it. */ +export function readDesign( + raw: Record, + pieceId: string | null, +): { design: Design; pieceId: string; suite: Suite; problem: string | null } { + const { suite, problem } = readSuite(raw); + const resolved = resolvePieceId(suite, pieceId); + return { design: designFor(suite, resolved), pieceId: resolved, suite, problem }; +} + +/** Writes the editor's view of one piece back into the wedding. */ +export function writeDesign(design: Design, pieceId: string, options: WriteOptions): void { + const { suite } = readSuite(useKnotworkStore.getState().raw); + writeSuite(withDesign(suite, pieceId, design), options); } diff --git a/suite/apps/plaque/state/store.ts b/suite/apps/plaque/state/store.ts index 32f860df..72c6d770 100644 --- a/suite/apps/plaque/state/store.ts +++ b/suite/apps/plaque/state/store.ts @@ -34,8 +34,13 @@ import type { import type { LoadedFont } from "../core/text/measure"; import type { PrinterProfile } from "../core/print/printerProfile"; import { useKnotworkStore, type WriteOptions } from "@/lib/store/useKnotworkStore"; -import { DESIGN_KEYS, designOf, initialDesign, type Design } from "./design"; -import { readDesign, writeDesign } from "./sliceBridge"; +import { DESIGN_KEYS, designFor, designOf, initialSuite, newPiece, type Design, type Suite } from "./design"; +import { readDesign, readSuite, writeDesign, writeSuite } from "./sliceBridge"; + +export interface PieceSummary { + id: string; + name: string; +} /** Every kind the registry knows about — see core/template/registry. */ export type NewElementKind = CardElement["kind"]; @@ -48,6 +53,15 @@ export interface PlaqueState extends Design { /** Why the wedding's saved design could not be read, when it could not. */ designProblem: string | null; + /** + * The piece on screen. This window's choice, not the wedding's: a partner + * opening the seating board does not move anybody else off the place cards, + * and undo never switches pieces. + */ + pieceId: string; + /** Every piece, in order, for the switcher. Same array until a name or the order changes. */ + pieces: PieceSummary[]; + /** * Parsed faces, keyed by fontId — bundled and uploaded alike. Held in the * store rather than a context so every panel and both renderers read fonts @@ -87,6 +101,15 @@ export interface PlaqueState extends Design { page: number; previewGuestIndex: number; + switchPiece: (id: string) => void; + /** A new, empty piece, opened. */ + addPiece: (name: string) => void; + /** A copy of a piece — design and data — opened. */ + duplicatePiece: (id: string) => void; + renamePiece: (id: string, name: string) => void; + /** The last piece cannot be removed: a wedding's stationery always has one. */ + removePiece: (id: string) => void; + setCsv: (data: { headers: string[]; rows: GuestRow[]; issues: RowIssue[]; fileName: string }) => void; setCard: (patch: Partial) => void; setSheet: (patch: Partial) => void; @@ -165,7 +188,9 @@ export const usePlaque = create()((set, get) => { if ((DESIGN_KEYS as readonly string[]).includes(key)) design[key as keyof Design] = value; else local[key as keyof PlaqueState] = value; } - if (Object.keys(design).length > 0) writeDesign({ ...designOf(s), ...(design as Partial) }, options); + if (Object.keys(design).length > 0) { + writeDesign({ ...designOf(s), ...(design as Partial) }, s.pieceId, options); + } if (Object.keys(local).length > 0) set(local as Partial); }; /** An edit: shown as "Undo