Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2616cce
Spec: seating stationery suite — live room, seat tokens, table and ro…
claude Oct 4, 2026
3898d81
Place cards: a stationery suite of named pieces
claude Oct 4, 2026
1edeb16
Place cards print from the room, live, with seat and table tokens
claude Oct 4, 2026
d11ca67
Place cards: boards at full size for a print shop, or tiled at home
claude Oct 4, 2026
7dc3288
Place cards: a grid element for seating boards, and two starters
claude Oct 4, 2026
757cba4
Place cards: an A-to-Z finder that carries on over pages
claude Oct 4, 2026
01bc943
Place cards: a room element — the floor plan, or one table's own map
claude Oct 4, 2026
8a134ee
Place cards: QR codes, "changed since printed", and an escort card
claude Oct 5, 2026
ec4623b
Seating's Print opens Place cards; its own card printing is retired
claude Oct 5, 2026
3be6932
Spec: seat-by-seat tokens picked on a map
claude Oct 5, 2026
2a23543
Spec: a name format per design for chair labels and chair tokens
claude Oct 5, 2026
e05f885
Place cards: a name format per design, and tokens for chairs
claude Oct 5, 2026
8839aa5
Place cards: chair names all one size, and a gap from the chair
claude Oct 5, 2026
8282f55
Place cards: pick a chair on a map instead of typing its token
claude Oct 5, 2026
7e8e58c
Place cards: stamp a plan's names into boxes of their own
claude Oct 5, 2026
37a30ed
Place cards: close the open issues from the seat picker build
claude Oct 5, 2026
1b327d9
Guests: a name of their own for the stationery
claude Oct 5, 2026
1daeab8
Place cards: fixes from a ground-up review
claude Oct 5, 2026
4286652
Place cards: readable table-card maps, one free-seating warning
claude Oct 5, 2026
0c9fbf7
Place cards: fixes from a final review
claude Oct 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions docs/superpowers/specs/2026-10-04-seating-stationery-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# 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 |
| F10 | Found while building phase 2: there is no CSV import left in Place cards. `setCsv` is only ever called with the room's rows (`DataPanel.tsx`: "There is no file import"). So a piece has no "file" source to keep, and the spec's `source` field is dropped: every piece prints from the room. | Traced (every `setCsv` caller) |
| F11 | A seat is stored twice, on the table and on the guest, and the tables win: `lib/seating/normalise.ts` rebuilds the guest side from `assignedGuestIds` on load, and the validator refuses a commit where they disagree. Seat lookups read the tables. | 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<PieceId, Piece>;
order: PieceId[];
activePieceId: PieceId;
/** Shared across pieces — a font uploaded once is everywhere. */
uploadedIcons, assetNames
}
interface Piece {
id, name;
card: CardSpec; sheet: SheetSpec; template: Template;
/** Guests printed together on one card, by guest id. */
merged: Record<string, string[]>;
printed: PrintRecord | null; // decision 9
}
```

A piece stores no rows (F10). Rows are derived from the document through the
per-document cache in `lib/model/slices.ts` (selectors must not allocate),
keyed by guest id, so per-guest tweaks and combined cards follow their people.
A save from before pieces is migrated: its stored rows go, and its tweaks and
combines are re-keyed from positions to guests by name, saying what could not
be matched.

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

As built (phase 3). A board is a card the size of the board — A3 to A0, 50 × 70
cm, 18 × 24 in and 24 × 36 in are card presets, and the card-size cap is A0 —
rather than a page size, so no custom page dimensions are stored:

- `A3` joins A4 and Letter as paper to impose on.
- `FIT` — "the card's own size" — makes the page the card plus its margins:
one board per page at full size, with bleed and crop marks. That is the
**print-shop** PDF. Home-printer warnings do not apply to it.
- **Tile** cuts each full-size sheet into A4, Letter or A3 (`sheet.tilePaper`)
with a 10 mm margin and a 10 mm overlap: trim lines on the inner edges,
landing lines where the next tile lays, and the tile's name in the slug
strip. A step after imposition (`core/imposition/tile.ts`), so neither
renderer changed.

### 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.
- **Finder** (as built, phase 5) — the grid's second layout, `columns`: blocks
per `{{Initial}}` flow down newspaper columns at the size asked for, lines
ordered by `sortBy` (surname). A list that fits one page is balanced across
its columns; a longer one is cut into one artefact per page before
imposition (`core/data/parts.ts`, `artefactsOf`), so preview, counts,
warnings and export see pages as artefacts and needed no change. This
retires the F9 limitation.
- **`room`** (as built, phase 6) — the floor plan from Seating's own geometry
pass (`layoutFloorPlan`, now exported), handed to core as a `RoomScene`
provider the way fonts and images are. It resolves into filled paths
(tables, chairs), lines (walls) and text (labels, names), so both renderers
draw it with no new code. Properties: show the whole room or just this
card's table (by `{{Table}}`), seat labels (first name / whole name / seat
number / none), font, largest name size, colours for names, tables, chairs
and walls, table labels and walls on or off. Names hang off each chair,
away from the table, as wide as the gap to the next chair, and stay upright
however a table is turned. Zones and doors are not drawn.
- **`qr`** — encodes a token (default `{{Guest Link}}`) as vector modules, so
it prints sharp at any size.

### Reprints

As built (phase 7). Each piece's `printed` holds when it was last exported
and a fingerprint per artefact of **what the design reads**: the columns it
binds (`columnsUsed`) and the part of the room a plan on it draws. Only what
the design reads, because renaming one table renumbers every table: a
whole-row fingerprint flagged 97 name cards for one renamed table, in the
browser, where 8 had changed. A notice names the changed cards and offers
*Print just these*; a reprint adds to the record, a full run replaces it.

### QR codes

`qr` elements encode any template, `{{Guest Link}}` by default — a room column
filled from the published guest link (empty, with a warning that says how to
publish one, otherwise). Encoded by `qrcode-generator` (MIT, no dependencies)
from UTF-8 bytes, drawn as one vector path through the icon pipeline. Proved
by decoding real renders with an independent decoder, accents and CJK
included.

## 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.
*As built:* Print offers floor plan, board, finder, place, escort and
table cards, each a link to `/place-cards?piece=<id>`, which opens that
piece or makes it from the starter of the same id. **Still a second
renderer:** the wedding PDF pack draws its one-page plan with Seating's
`buildFloorPlanPdf`; moving the pack onto a Place cards piece is the
follow-up that would make it one way.

## Open questions, to settle at the start of their phase

- ~~Phase 4: what a grid does with an unseated guest~~ — settled: left off
the board, with a warning counting them. A board at the door is public, and
a "Still to seat" block is a job list, not signage.
- ~~Phase 6: part of the room~~ — settled for now: the whole room, or one
table. A part (one marquee of two) is a later addition.
- 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.
Loading
Loading