This is a public, read-only report. Striff reads this repository's docs, turns each sentence that makes a claim about the code into a rule, and checks the rule against the code on the default branch. How this works
They belong outside the port (src/ proper or table-sync/).
4 rules from this sentence
shufflerPublicUrl may not be in apps/shuffler/src/port-spine
Holds
cardBackImageUrl may not be in apps/shuffler/src/port-spine
Holds
defaultPlaymatImageUrl may not be in apps/shuffler/src/port-spine
Holds
playmatImageUrlFromPath may not be in apps/shuffler/src/port-spine
Holds
Read Oct 19
It is insensitive to CardDefinition field changes — which is why it stayed at 2 through the cardTypes change.
PersistedDeck may not depend on CardDefinition
Holds
Read Oct 19
Inside inSpan, don't use it — inSpan already records the exception on the span.
inSpan may not depend on logError
Holds
Read Oct 19
Don't add a manual currentTraceparent() + header-attach for a new fetch/XHR call site; that shape is retired (DiagnosticButton.tsx no longer does it) and copying it now would be redundant with what auto-instrumentation already does.
DiagnosticButton may not depend on apps.tabletop.src.client.observability.index.currentTraceparent
Holds
Read Oct 19
No ShapeUtil, no client-side selection/drag mechanics touched — this split is pure server-side event routing and mint-position logic, the same "identity contract every hook in MtgCardShapeUtil depends on, not this owner's mechanics territory per se" category this file was already in.
3 rules from this sentence
applyCardArrival may not depend on MtgCardShapeUtil
Holds
applyCardDiscard may not depend on MtgCardShapeUtil
Holds
dispatchSpineEvent may not depend on MtgCardShapeUtil
Holds
Read Oct 19
wire/port shapes (SpineJoinRequest, SpineJoinResult, SpinePort) → stay in the port
2 rules from this sentence
SpineJoinRequest is in apps/shuffler/src/port-spine
Holds
SpineJoinResult is in apps/shuffler/src/port-spine
Holds
Read Oct 19
env reading (shufflerPublicUrl, cardBackImageUrl, defaultPlaymatImageUrl, playmatImageUrlFromPath) → these are Shuffler-side facts about our own public URLs, not Spine facts. They belong outside the port (src/ proper or table-sync/).
4 rules from this sentence
shufflerPublicUrl is in apps/shuffler/src, and shufflerPublicUrl is not in apps/shuffler/src/port-spine
Holds
cardBackImageUrl is in apps/shuffler/src, and cardBackImageUrl is not in apps/shuffler/src/port-spine
Holds
defaultPlaymatImageUrl is in apps/shuffler/src, and defaultPlaymatImageUrl is not in apps/shuffler/src/port-spine
Holds
playmatImageUrlFromPath is in apps/shuffler/src, and playmatImageUrlFromPath is not in apps/shuffler/src/port-spine
Holds
Read Oct 19
Holds the join port, SpineJoinAdapter, the join gateway, and the seat-decoration builders.
SpineJoinAdapter is in apps/shuffler/src/port-spine/join
Holds
Read Oct 19
SpineEventsPort can send an event to the table AND subscribe to events from the table. Holds that one port, its adapter, and its gateway(s).
SpineEventsPort is in apps/shuffler/src/port-spine/events
Holds
Read Oct 19
tableUrl stays on SeatAtTable in the join port, and that is deliberate.
SeatAtTable is in apps/shuffler/src/port-spine/join, and SeatAtTable has tableUrl
Holds
Read Oct 19
the Shuffler mints a short-GUID seatId at join and records all three on BOTH PersistedGamePrep and PersistedGameState (optional fields, no version bumps).
6 rules from this sentence
PersistedGamePrep has a seatId field
Holds
PersistedGameState has a seatId field
Holds
PersistedGamePrep has a tableName field
checks “records all three on BOTH PersistedGamePrep and PersistedGameState”
Holds
PersistedGamePrep has a playerName field
checks “records all three on BOTH PersistedGamePrep and PersistedGameState”
Holds
PersistedGameState has a tableName field
checks “records all three on BOTH PersistedGamePrep and PersistedGameState”
Holds
PersistedGameState has a playerName field
checks “records all three on BOTH PersistedGamePrep and PersistedGameState”
Holds
Read Oct 19
Joining a table is one call to the Spine: /start-game, /restart-game, and /yo all call joinSpineBestEffort() (src/table-sync/sendToSpine.ts) once, carrying identity (gameId, table name, player name) and the full seat decoration (deck name, playmat, card-back, sleeve, resolved primary/secondary colors, commanders, and this game's own gameUrl).
joinSpineBestEffort is in apps/shuffler/src/table-sync
Holds
Read Oct 19
There is no TabletopPort, HttpTabletopGateway, or FakeTabletopGateway in this ship; src/port-tabletop/ holds only the card.played envelope shape (buildCardPlayedEvent) that both sendCardPlayedToSpineBestEffort and the Spine join's decoration payload build against.
buildCardPlayedEvent is in apps/shuffler/src/port-tabletop
Holds
Read Oct 19
TableInfo, GameState, and the persisted game/prep all carry tableUrl alongside spineTableId/spineSeatNumber (optional, no version bump).
9 rules from this sentence
TableInfo has tableUrl
Holds
TableInfo has spineTableId
Holds
TableInfo has spineSeatNumber
Holds
GameState has tableUrl
Holds
GameState has spineTableId
Holds
GameState has spineSeatNumber
Holds
PersistedGamePrep has tableUrl
checks “TableInfo, GameState, and the persisted game/prep all carry tableUrl alongside spineTableId/spineSeatNumber”
Holds
PersistedGamePrep has spineTableId
checks “TableInfo, GameState, and the persisted game/prep all carry tableUrl alongside spineTableId/spineSeatNumber”
Holds
PersistedGamePrep has spineSeatNumber
checks “TableInfo, GameState, and the persisted game/prep all carry tableUrl alongside spineTableId/spineSeatNumber”
Holds
Read Oct 19
JoinTablePort (types.ts) speaks the Shuffler's domain: join(JoinTableRequest) answering a SeatAtTable.
3 rules from this sentence
JoinTablePort has a join method
checks “join(JoinTableRequest) answering a SeatAtTable”
Holds
JoinTablePort depends on JoinTableRequest
checks “join(JoinTableRequest)”
Holds
JoinTablePort depends on SeatAtTable
checks “answering a SeatAtTable”
Holds
Read Oct 19
The abstract SpineJoinAdapter owns the translation into the Spine's /join wire vocabulary (spineWire.ts), including the seat-decoration builders; HttpSpineJoinAdapter (real) and FakeSpineJoinAdapter (tests, and the in-memory seat-allocation policy — one table per name, seats 1-4, idempotence by the request's joinRequestId) subclass it and supply only submission.
3 rules from this sentence
SpineJoinAdapter is declared abstract
checks “The abstract SpineJoinAdapter”
Holds
HttpSpineJoinAdapter extends SpineJoinAdapter
Holds
FakeSpineJoinAdapter extends SpineJoinAdapter
Holds
Read Oct 19
The receive half is one more method on the same port: followTable(tableId, applyEvent, appliedThrough) — "deliver me the events at this table, resuming after what I applied".
SpineEventsPort has a followTable method
Holds
Read Oct 19
both the Spine and the Tabletop validate against the same contracts/envelope.v1.json — sendEventposts the full EventEnvelope unchanged, traceparent included.
EventEnvelope has traceparent
Holds
Read Oct 19
each GameCard gets a cardInstanceId GUID (minted in newGame, mint-on-load for old saves).
GameCard has cardInstanceId
Holds
Read Oct 19
Below the adapter, HttpSpineStreamGateway.ts is the thin transport: streamed fetch, data: <json>\n\n frames, and the same heartbeat-aware bounded headersTimeout/bodyTimeoutundiciAgent as the Tabletop's (this is the Shuffler's first undici dependency, pinned to major version 7 to match what Node vendors internally, same reasoning as the Tabletop's pin).
HttpSpineStreamGateway imports undici
Holds
Read Oct 19
FakeSpineEventsAdapter is the port-level fake for both directions and takes a stream gateway.
FakeSpineEventsAdapter depends on SpineStreamGateway
Holds
Read Oct 19
seq is legitimately domain data here: it's a field of the published envelope contract, typed on the validated Envelope, and persisted.
Envelope has a seq field
Holds
Read Oct 19
getCardImageUrl() prefers the stored URL and falls back to constructCardImageUrl() (bare CDN path) — the bare path 404s for freshly-released cards, which is why we store the versioned URLs.
getCardImageUrl depends on constructCardImageUrl
Holds
Read Oct 19
Card images in src/port-card-images/ (CardImagesPort): ScryfallCardImagesGateway (batched, throttled, 429-retrying), FakeCardImagesGateway, and enrichDeckWithImages().
4 rules from this sentence
CardImagesPort is in apps/shuffler/src/port-card-images
checks “Card images** in src/port-card-images/ (CardImagesPort)”
Holds
ScryfallCardImagesGateway is in apps/shuffler/src/port-card-images
FakeCardImagesGateway is in apps/shuffler/src/port-card-images
checks “FakeCardImagesGateway”
Holds
enrichDeckWithImages is in apps/shuffler/src/port-card-images
checks “enrichDeckWithImages()”
Holds
Read Oct 19
The port is an interface, RetrieveDeckPort.
RetrieveDeckPort is a contract
Holds
Read Oct 19
JoinTablePort.join(JoinTableRequest): SeatAtTable, and SpineEventsPort, which covers both directions of one capability — announceCardPlayed/announceCardReturned/announceCardDiscarded going out, and followTable(tableId, applyEvent, appliedThrough) coming in.
checks “and SpineEventsPort, which covers both directions of one capability — announceCardPlayed/announceCardReturned/announceCardDiscarded going out”
Holds
SpineEventsPort has announceCardReturned
checks “and SpineEventsPort, which covers both directions of one capability — announceCardPlayed/announceCardReturned/announceCardDiscarded going out”
Holds
SpineEventsPort has announceCardDiscarded
checks “and SpineEventsPort, which covers both directions of one capability — announceCardPlayed/announceCardReturned/announceCardDiscarded going out”
Holds
SpineEventsPort has a followTable method
checks “and SpineEventsPort, which covers both directions of one capability — announceCardPlayed/announceCardReturned/announceCardDiscarded going out, and followTable(tableId, applyEvent, appliedThrough) coming in.”
Holds
Read Oct 19
Each capability has an abstract adapter — SpineJoinAdapter, SpineEventsAdapter — holding all the translation (and, for events, the reconnect orchestration), with Http* and Fake* subclasses supplying only transport.
2 rules from this sentence
SpineJoinAdapter is declared abstract
Holds
SpineEventsAdapter is declared abstract
Holds
Read Oct 19
All three gateways wrap their failures in one shared SpineGatewayError — one service, one error type.
3 rules from this sentence
HttpSpineJoinGateway depends on SpineGatewayError
Holds
HttpSpineEventsGateway depends on SpineGatewayError
Holds
HttpSpineStreamGateway depends on SpineGatewayError
Holds
Read Oct 19
tableUrl on SeatAtTable (rendered as the "Go to Table" link) and seq (recorded as GameEvent.spineSeq so a torn-down subscription can resume).
SeatAtTable has tableUrl
Holds
Read Oct 19
Constant PERSISTED_DECK_VERSION, Defined in src/types.ts, Current 4, Stamped on every Deck (adapters + hydrateDeck ), Validated (fails) at LocalFileAdapter.retrieveDeck → DeckVersionMismatchError
hydrateDeck depends on Deck
Holds
Read Oct 19
Constant PersistedDeck.version, Defined in src/port-persist-state/persisted-types.ts, Current 2, Stamped on PersistedDeck ( dehydrateDeck ), Validated (fails) at not validated on load (dehydrated form)
2 rules from this sentence
PersistedDeck has version
checks “Constant PersistedDeck.version”
Holds
dehydrateDeck depends on PersistedDeck
checks “Stamped on PersistedDeck ( dehydrateDeck )”
Holds
Read Oct 19
decks/*.json (read by LocalFileAdapter) embed full Deck objects with full CardDefinitions.
LocalFileAdapter depends on Deck
Holds
Read Oct 19
PersistedGameState (a saved game) stores cards as cardDefinitionId references (PersistedGameCard) and rehydrates full CardDefinitions from the card repository at load.
PersistedGameState depends on PersistedGameCard
Holds
Read Oct 19
PersistedGamePrep (a saved prep) embeds a full Deck (with CardDefinitions).
PersistedGamePrep depends on Deck
Holds
Read Oct 19
PersistedDeck (the dehydrated deck inside persisted game state) stores only cardDefinitionIds + provenance.
PersistedDeck has provenance
Holds
Read Oct 19
Card repository (SqliteCardRepositoryAdapter, gitignored data.db) is a cache of CardDefinitions keyed by card_definition_id.
SqliteCardRepositoryAdapter depends on CardDefinition
Holds
Read Oct 19
chooseLicenseKey (src/client/chooseLicenseKey.ts) withholds any baked key wherever the gate can't fire, so a stale key in .be can't blank local dev or http prod. See README → Licensing.
chooseLicenseKey is in apps/tabletop/src/client
Holds
Read Oct 19
projectEvents(events) -> TableState is a pure function on this ship, and the Spine makes no code change beyond registering the new payload schemas.
projectEvents depends on TableState
Holds
Read Oct 19
It holds seats, seenEventIds, spineTableId, spineSubscription and stackCardCount() — none of which are tldraw's business — plus a raw .room it never encapsulates, which every module mutates directly (entry.seenEventIds.add, playerArea.graveyardCount++, entry.seats.set).
6 rules from this sentence
RoomEntry has seats
Holds
RoomEntry has seenEventIds
Holds
RoomEntry has spineTableId
checks “spineTableId”
Holds
RoomEntry has spineSubscription
checks “spineSubscription”
Holds
RoomEntry has a stackCardCount method
checks “stackCardCount()”
Holds
RoomEntry has room
checks “a raw .room”
Holds
Read Oct 19
the wiring does not.** projectEvents(events) -> TableState and snapshotCanvas(editor) -> TableState produce the same shape so they can be diffed, and diffTableStates diffs them.
diffTableStates depends on TableState
Holds
Read Oct 19
The server holds the Spine subscription, so it is the only place the event log arrives.
SpineSubscription is in apps/tabletop/src/server
Holds
Read Oct 19
It also already reads live table state out of the room — hasInstance and stackCardCount in src/server/rooms.ts both walk room.getCurrentSnapshot().documents, filtering typeName === "shape" and reading props.
2 rules from this sentence
hasInstance is in apps/tabletop/src/server
Holds
stackCardCount is in apps/tabletop/src/server
Holds
Read Oct 19
The clearest tell is applyCardArrival(tableName, body: unknown) in src/server/cardArrival.ts: it accepts a raw envelope, runs Ajv, checks slugifyTableName(envelope.tableId) !== tableName, sniffs whether name === "card.played-face-down", and only then places a shape.
2 rules from this sentence
applyCardArrival is in apps/tabletop/src/server
checks “applyCardArrival(tableName, body: unknown) in src/server/cardArrival.ts”
src/log.ts in the Shuffler, src/server/log.ts in the Tabletop, logError() in the Tabletop's browser wrapper.
logError is in apps/tabletop/src/client/observability
Holds
Read Oct 19
If the card shape's rotation-delta convention in cardTap.ts (TAP_ANGLE, now exported for exactly this reuse) or its center-preserving pivot math ever changes, this counter math must change with it — it re-derives the same before/after rotation the card's own catch-up assumes.
TAP_ANGLE is exported
Holds
Read Oct 19
HTML that interpolates user-supplied text must escape it. Deck names come from Archidekt. escapeHtml lives in src/view/common/shared-components.ts (moved there from active-game-page.ts on 2026-08-07, where it was module-private) — import it rather than writing a second copy.
escapeHtml is in apps/shuffler/src/view/common
Holds
Read Oct 19
All four background-tint call sites — command-zone surround and deck-title on both /prepare (prep-view-helpers.ts) and /game (shared-components.ts's formatCommandZoneHtmlFragment, active-game-page.ts) — now call sleeveTintStyle(secondaryColor, …) instead of sleeveTintStyle(sleeveColor, …).
formatCommandZoneHtmlFragment depends on sleeveTintStyle
Holds
Read Oct 19
The mana-pie hexes live twice by design: --mana-* in packages/design-tokens/tokens.css and SLEEVE_QUICK_PICKS in apps/shuffler/src/table-look.ts (domain data — a CSS var() can't be a persisted value). The table-look.ts comment says "change a token there, visit here"; honour it.
SLEEVE_QUICK_PICKS is in apps/shuffler/src
Holds
Read Oct 19
/game now paints the picked mat too — CLOSED (2026-08-09).GameState/ PersistedGameState snapshot sleeveColor/playmatImagePath off PersistedGamePrep at /start-gameand/restart-game (GameState.newGame gained two trailing optional params; both call sites in app.ts pass prep.sleeveColor, prep.playmatImagePath).
3 rules from this sentence
GameState has newGame
checks “GameState.newGame gained two trailing optional params”
Holds
PersistedGamePrep has sleeveColor
checks “CLOSED (2026-08-09).** GameState/ PersistedGameState snapshot sleeveColor/playmatImagePath off PersistedGamePrep at /start-game and /restart-game (GameState.newGame gained two trailing optional params; both call sites in app.ts pass prep.sleeveColor, prep.playmatImagePath).”
Holds
PersistedGamePrep has playmatImagePath
checks “CLOSED (2026-08-09).** GameState/ PersistedGameState snapshot sleeveColor/playmatImagePath off PersistedGamePrep at /start-game and /restart-game (GameState.newGame gained two trailing optional params; both call sites in app.ts pass prep.sleeveColor, prep.playmatImagePath).”
Holds
Read Oct 19
It lives in apps/tabletop/src/server/tableFurniture.ts → ensurePlayerArea (grep toRichText) — a server-side draw, not a ShapeUtil.
ensurePlayerArea is in apps/tabletop/src/server
Holds
Read Oct 19
LIBRARY_PILE_INSET (12) in src/shared/mtgZoneShape.ts is shared server/client — tableFurniture.ts uses it for the card-back image geometry, MtgZoneShapeUtil for the sleeve pile.
2 rules from this sentence
LIBRARY_PILE_INSET is in apps/tabletop/src/shared
checks “LIBRARY_PILE_INSET (12) in src/shared/mtgZoneShape.ts is shared server/client”
Holds
MtgZoneShapeUtil depends on LIBRARY_PILE_INSET
Holds
Read Oct 19
The sleeve treatment lives in two .tsx files — the card's ring in MtgCardShapeUtil.tsx (the sleeve object), the library pile in MtgZoneShapeUtil.tsx (the sleevePile block). Changing the sleeve look means both.
2 rules from this sentence
MtgCardShapeUtil is in apps/tabletop/src/client/shapes
checks “the card's ring in MtgCardShapeUtil.tsx”
Holds
MtgZoneShapeUtil is in apps/tabletop/src/client/shapes
checks “the library pile in MtgZoneShapeUtil.tsx”
Holds
Read Oct 19
ticket 11 (what a zone looks like) no longer does — it landed as inline CSSProperties objects in MtgZoneShapeUtil.tsx (ticket 14, 2026-08-08, see below), not CSS, so it didn't end up needing a stylesheet after all.
MtgZoneShapeUtil uses CSSProperties
Holds
Read Oct 19
Ticket 18's counter disc (2026-08-08) made the same call — inline CSSProperties with var() tokens in MtgCounterShapeUtil.tsx, deliberately not starting a stylesheet.
MtgCounterShapeUtil uses CSSProperties
Holds
Read Oct 19
Confirmed working, not just argued, 2026-08-08 (tabletop-physics ticket 13): MtgZoneShapeUtil's component() sets fontFamily: "var(--font-chrome)" on a plain div inside HTMLContainer, and it resolves to Orbitron — checked both by reading the DOM's computed font-family in a live browser and by screenshot.
MtgZoneShapeUtil has a component method
Holds
Read Oct 19
tldraw owns that metric (its own stroke geometry, and canvas text can't use the fleet fonts anyway — same font-enum limit), so there's no token or CSS to read it from: NAME_TEXT_BASELINE in apps/tabletop/src/server/cardLayout.ts is measured off the live render.
NAME_TEXT_BASELINE is in apps/tabletop/src/server
Holds
Read Oct 19
The library pile is rendered by MtgZoneShapeUtil itself via a new sleeveColor prop on mtg-zone: an inner solid rect inset by the shared LIBRARY_PILE_INSET (12, moved to apps/tabletop/src/shared/mtgZoneShape.ts, used by server image geometry and client sleeve geometry alike), square-cornered like the sleeved cards (its 5%-of-inset-width radius went in the same e53a27e).
MtgZoneShapeUtil depends on LIBRARY_PILE_INSET
Holds
Read Oct 19
an inner solid rect inset by the shared LIBRARY_PILE_INSET (12, moved to apps/tabletop/src/shared/mtgZoneShape.ts, used by server image geometry and client sleeve geometry alike), square-cornered like the sleeved cards (its 5%-of-inset-width radius went in the same e53a27e).
LIBRARY_PILE_INSET is in apps/tabletop/src/shared
Holds
Read Oct 19
the darker of the deck's two identity colors** — a new color prop on the mtg-title shape (src/shared/mtgTitleShape.ts, single source, in both client and server createTLSchema), computed server-side in tableFurniture.ts by the exported darkerColor(a?, b?) (relative-luminance compare, pick darker).
darkerColor is exported
Holds
Read Oct 19
Recording that something happened: never span.addEvent. Attributes on the span you're in — always the first choice — or, when there's no span to hang it on, log.info/warn/error from that ship's log.ts (the two Node ships) or logError() in the Tabletop's browser. The Spine doesn't have one yet (spine-logs-in-traces in TODO.md).
logError is in apps/tabletop/src/client
Holds
Read Oct 19
GameState.recordSpineJoin itself stamps nothing; telemetry stays in the HTTP layer.
GameState has recordSpineJoin
Holds
Read Oct 19
Adding a game-mutation route in apps/shuffler/src/app.ts: all 13 game-mutation routes go through apply-game-command.ts's applyGameCommand(), which owns the "not-found"/"incompatible-version" markCurrentSpanAsError calls for all of them — don't re-add per-route copies of those two.
applyGameCommand depends on markCurrentSpanAsError
Holds
Read Oct 19
applyGameCommand also setCommonSpanAttributes({ tableName, playerName }) right after the game loads, so every mutation route gets table.name/player.name for free — a new mutation route needs nothing extra.
applyGameCommand depends on setCommonSpanAttributes
Holds
Read Oct 19
The Shuffler reaches exactly one other ship, through one directory: port-spine/. Since the 2026-09-06 layering refactor that directory holds two ports, one per capability — join/ (JoinTablePort) and events/ (SpineEventsPort, both directions) — each with an adapter (domain↔envelope translation, reconnect orchestration) over a gateway (the actual fetch).
2 rules from this sentence
JoinTablePort is in apps/shuffler/src/port-spine/join
Holds
SpineEventsPort is in apps/shuffler/src/port-spine/events
Holds
Read Oct 19
apps/shuffler/src/port-tabletop/ holds envelope-shape helpers only (buildCardPlayedEvent, the CardPlayedEvent/EventEnvelope types) — don't add an HTTP client back into it on the theory the directory name implies one.
3 rules from this sentence
CardPlayedEvent is in apps/shuffler/src/port-tabletop
Holds
EventEnvelope is in apps/shuffler/src/port-tabletop
Holds
buildCardPlayedEvent is in apps/shuffler/src/port-tabletop
Holds
Read Oct 19
Filtering Shuffler→Spine failures on exception.type: as of 2026-09-06 it is SpineGatewayError for all of them — join, event send, and SSE connection drop — where it used to be Error (non-2xx) or TypeError (unreachable host, undici timeout). All three gateways wrap, preserving message verbatim and the original as cause; span names, attribute keys, and log strings are byte-identical.
2 rules from this sentence
SpineEventsGateway depends on SpineGatewayError
Holds
SpineJoinGateway depends on SpineGatewayError
Holds
Read Oct 19
undici.Agent lifetime under the port/adapter/gateway layering: createHeartbeatAwareDispatcher() is a constructor default on HttpSpineStreamGateway, which is itself a constructor default on HttpSpineEventsAdapter, of which server.ts builds exactly one — one connection pool per process, down from one per subscription.
HttpSpineEventsAdapter depends on HttpSpineStreamGateway
Holds
Read Oct 19
Also stamp table.name via tableNameFromSlug(tableName) (apps/tabletop/src/shared/slugify.ts), never the raw route param — since TableSlug.mint (services/spine/lib/table_slug.rb) the Tabletop's tableName/tableSlug param is an id-bearing slug (<name-slug>-<8-hex>), not a bare display name; stamping it raw as table.name breaks the cross-ship filter.
tableNameFromSlug is in apps/tabletop/src/shared
Holds
Read Oct 19
TablePage.tsx (the browser side, mtg-tabletop-web dataset) does NOT yet do this — its setGlobalAttrs({ "table.name": tableSlug }) and inSpan("table page opened", ...) calls still stamp the raw, id-bearing slug.
TablePage is in apps/tabletop/src/client
Holds
Read Oct 19
Wired into TablePage.tsx alongside useCardArrivalSpans/usePhysicsAnnouncements, same inSpan() idiom.
2 rules from this sentence
TablePage depends on useCardArrivalSpans
Holds
TablePage depends on usePhysicsAnnouncements
Holds
Read Oct 19
envelope.v1.json carries traceparent as a real, optional envelope field (never required — "it's rude to fail on tracing"), so HttpSpineEventsGateway.sendEvent no longer strips it: it builds the envelope via the Tabletop-facing helper (shared plumbing, buildCardPlayedEvent) and posts it as-is.
HttpSpineEventsGateway has sendEvent
Holds
Read Oct 19
For that shape, currentTraceparent() is still the right tool and is still exported for exactly this caller.
currentTraceparent is exported
Holds
Read Oct 19
logError(message, attributes, error?) in apps/tabletop/src/client/observability/index.ts.
logError is in apps/tabletop/src/client/observability
Holds
Read Oct 19
Likewise, dispatchSpineEventForGame still opens the span itself and awaits all its work insidestartActiveSpan's callback, and SpineEventsAdapter.followTable awaits applyEvent inline (serializing a replay burst through a processingChain promise so events stay in arrival order) — so the port never opens a span and the caller's span always encloses the work.
SpineEventsAdapter has followTable
Holds
Read Oct 19
Wired into TablePage.tsx alongside useCardArrivalSpans/ usePhysicsAnnouncements, using the same inSpan("name", () => {}, {attrs}) idiom as useCardArrivalSpans.ts.
2 rules from this sentence
TablePage depends on useCardArrivalSpans
Holds
TablePage depends on usePhysicsAnnouncements
Holds
Read Oct 19
The ignoreUrls shape matters, and a plain string would have silently missed a case.buildExportDestinationIgnoreUrls(config, origin) (same file, pure and exported) resolves config.tracesUrl/config.logsUrl against location.href into absolute-URL RegExps.
buildExportDestinationIgnoreUrls is exported
Holds
Read Oct 19
currentTraceparent() itself stays exported from observability/index.ts for exactly this caller.
currentTraceparent is exported
Holds
Read Oct 19
Shuffler side.SpineJoinResult (apps/shuffler/src/port-spine/join/spineWire.ts) and SpineJoinOutcome (apps/shuffler/src/table-sync/sendToSpine.ts) carry seatId through;
2 rules from this sentence
SpineJoinResult has seatId
Holds
SpineJoinOutcome has seatId
Holds
Read Oct 19
GameState.recordSpineJoin (apps/shuffler/src/GameState.ts) adopts outcome.seatId as this.seatId when present (the field is no longer readonly) — deliberately with no telemetry call inside GameState itself, per the rule that stamping stays in the HTTP/command layer, never the domain model.
2 rules from this sentence
GameState has recordSpineJoin
Holds
GameState has a seatId field
Holds
Read Oct 19
Retrieves PersistedGameState from persistence, reconstructs GameState via GameState.fromPersistedGameState(), gets library cards via game.listLibrary().
GameState has a fromPersistedGameState method
Holds
Read Oct 19
GameState.listLibrary() (src/GameState.ts):
GameState has a listLibrary method
Holds
Read Oct 19
Returns read-only array of GameCard & { location: LibraryLocation }
2 rules from this sentence
GameState depends on GameCard
Holds
GameState depends on LibraryLocation
Holds
Read Oct 19
game.listLibrary() - provides the cards to display
GameState has a listLibrary method
Holds
Read Oct 19
game.getStateVersion() - used for optimistic concurrency on card modal links
GameState has a getStateVersion method
Holds
Read Oct 19
If CardDefinition fields change (especially cardTypes, colorIdentity), the library search template mapping in src/app.ts needs updating (search for app.get("/library-modal/:gameId" and app.get("/prep-library-modal/:prepId" — line numbers drift).
2 rules from this sentence
CardDefinition has a cardTypes field
Holds
CardDefinition has a colorIdentity field
Holds
Read Oct 19
Archidekt custom cards (ArchidektDeckToDeckAdapter.convertCustomCard, deck API customCards) carry no Scryfall data, so the adapter builds the two fields library search reads:
apps/tabletop/src/server/cardArrival.ts exports applyCardArrival(tableName, body) — the shared validation/dedup/placement logic for a card.played event.
applyCardArrival is in apps/tabletop/src/server
Holds
Read Oct 19
In production, applyCardArrival is called exclusively by the Spine SSE dispatcher, apps/tabletop/src/server/spineEventDispatch.ts's dispatchSpineEvent — it subscribes to the Spine's per-table SSE stream, filters for card.played, continues the trace from the broadcast envelope's traceparent, and calls applyCardArrival inside that span.
dispatchSpineEvent is in apps/tabletop/src/server
Holds
Read Oct 19
Ticket 01 (2026-08-11, organizational split — see "Ticket 01" section below for the full writeup).apps/tabletop/src/client/shapes/MtgCardShapeUtil.tsx is now a thin shell: it still extends tldraw's BaseBoxShapeUtil<MtgCardShape> (not ImageShapeUtil — see "Ticket 12 landed" below) and still declares every override below — the override's mere presence is load-bearing regardless of its body, see the onClick-defers-selection quirk further down — but each override's body now lives in a sibling file and is called with this.editor passed through explicitly.
MtgCardShapeUtil is in apps/tabletop/src/client/shapes
Holds
Read Oct 19
it still extends tldraw's BaseBoxShapeUtil<MtgCardShape> (not ImageShapeUtil — see "Ticket 12 landed" below) and still declares every override below — the override's mere presence is load-bearing regardless of its body, see the onClick-defers-selection quirk further down — but each override's body now lives in a sibling file and is called with this.editor passed through explicitly.
MtgCardShapeUtil depends on MtgCardShape
Holds
Read Oct 19
Second independent passenger-eviction implementation in the codebase — deliberately simpler, not shared with the first.evictPassengers (cardZoneEntry.ts, client-side, watch point 26)spot-finds an open position near a zone's edge and detaches passengers there, because the card is landing somewhere else on the table and a passenger needs a place to land too.
evictPassengers is in apps/tabletop/src/client
Holds
Read Oct 19
Every existing onTranslateEnd consumer (zone-entry logging, passenger eviction, stack-landing nudge, all inside handleTranslateEnd) resolves its zone from the dropped shape's own center (zoneAt() → getShapePageBounds(shape).center).
handleTranslateEnd depends on zoneAt
Holds
Read Oct 19
evictPassengers (cardZoneEntry.ts) was a private helper; it's now exported specifically so swallowCard (cardSwallow.ts, new) can call it directly before starting the swallow animation — otherwise a counter or note riding the swallowed card would be silently deleted with its host, orphaned, or left parented to a shape that no longer exists.
swallowCard depends on evictPassengers
Holds
Read Oct 19
BaseBoxShapeUtil.getInterpolatedProps — editor.animateShapes only interpolates x/y/rotation/opacity out of the box; a custom shape's own props need an explicit override to tween instead of snapping at the animation's end.MtgCardShapeUtil adds:
MtgCardShapeUtil has getInterpolatedProps
Holds
Read Oct 19
LibraryPortalOverlay.tsx instead renders in viewport space (editor.pageToViewport, following the armed zone's page bounds through zoom/pan), wired via TLComponents.InFrontOfTheCanvas in TablePage.tsx's components object next to the existing Toolbar/ContextMenu overrides. It reads a new useArmedLibraryZoneId(editor) — a thin narrowing of the same armedZoneIdSignal every other zone's glow reads, filtered to zone === "library" — so it's the identical arm/disarm decision, rendered through a different slot because this one shape has opaque content stacked on top of it that a component()-level treatment can't paint over.
LibraryPortalOverlay depends on useArmedLibraryZoneId
Holds
Read Oct 19
the server-side arrival cascade (stackCardPosition, apps/tabletop/src/server/cardLayout.ts) keeps consecutive same-seat arrivals visibly offset, but that only covers a card's first landing on the Stack via card.played — a card dragged there by a player could land squarely on top of another card and fully hide it, with nothing to notice or correct it.
stackCardPosition is in apps/tabletop/src/server
Holds
Read Oct 19
Client — apps/tabletop/src/client/TablePage.tsx:
TablePage is in apps/tabletop/src/client
Holds
Read Oct 19
mtgCardShapeMigrations in src/shared/mtgCardShape.ts (createShapePropsMigrationIds("mtg-card", { RenameScryfallId: 1 }), up/down move the value between keys).
mtgCardShapeMigrations is in apps/tabletop/src/shared
Holds
Read Oct 19
apps/tabletop/src/shared/mtgZoneShape.ts (props/validators/TLGlobalShapePropsMap registration, same pattern as mtgCardShape.ts) and apps/tabletop/src/client/shapes/MtgZoneShapeUtil.tsx (BaseBoxShapeUtil<MtgZoneShape>).
MtgZoneShape is in apps/tabletop/src/shared
Holds
Read Oct 19
The playmat's own picture is a prop on this shape, not a second shape (2026-08-11).MtgZoneShapeProps gained imageUrl: string | null, set only on zone: "playmat", same lifecycle as sleeveColor — written once at mint time in tableFurniture.ts's ensurePlayerArea, never mutated by a player action.
MtgZoneShapeProps has imageUrl
Holds
Read Oct 19
The same change made every pair of zone AABBs strictly disjoint (20-unit GAP, exported from cardLayout.ts), pairwise-asserted in test/cardLayout.test.ts, because an overlap would resolve via topmostZoneAt()'s draw-order tiebreak — deterministic but semantically meaningless. See watch point 8.
GAP is exported
Holds
Read Oct 19
apps/tabletop/src/client/shapes/zoneHitTest.ts (new) extracts the hit test into topmostZoneAt(editor, center), returning { id, zone } for the topmost mtg-zone shape whose page bounds contain center (same greatest-index tie-break as before, now with its rationale and watch-point-8 caveat written once instead of duplicated per caller).
topmostZoneAt is in apps/tabletop/src/client/shapes
Holds
Read Oct 19
MtgCardShapeUtil.zoneAt() now calls topmostZoneAt(...)?.zone instead of walking shapes itself.
MtgCardShapeUtil depends on topmostZoneAt
Holds
Read Oct 19
the same file also exports useIsZoneArmed(editor, zoneId), built on one computed() per Editor instance (a lazy WeakMap<Editor, Computed<TLShapeId | undefined>> — not one computed per zone shape).
useIsZoneArmed is in apps/tabletop/src/client/shapes
Holds
Read Oct 19
MtgZoneShapeUtil.component() calls useIsZoneArmed(this.editor, shape.id) unconditionally at the top, before any conditional branching, purely to read derived state for rendering.
ZoneHit widened from {id, zone} to {id, zone, seatId} — topmostZoneAt now reads the winning candidate's props.seatId too, since the new check needs it.
4 rules from this sentence
ZoneHit has id
checks “ZoneHit widened from {id, zone} to {id, zone, seatId}”
Holds
ZoneHit has zone
checks “ZoneHit widened from {id, zone} to {id, zone, seatId}”
Holds
ZoneHit has seatId
checks “ZoneHit widened from {id, zone} to {id, zone, seatId}”
Holds
topmostZoneAt depends on ZoneHit
checks “ZoneHit widened from {id, zone} to {id, zone, seatId} — topmostZoneAt now reads the winning candidate's props.seatId too”
Holds
Read Oct 19
apps/tabletop/src/shared/mtgLifeCounterShape.ts (MtgLifeCounterShapeProps = {w, h, value}, the standard TLGlobalShapePropsMap augmentation) and apps/tabletop/src/client/shapes/MtgLifeCounterShapeUtil.tsx (BaseBoxShapeUtil, no interaction hooks — same as mtg-zone).
5 rules from this sentence
MtgLifeCounterShapeProps is in apps/tabletop/src/shared
checks “apps/tabletop/src/shared/mtgLifeCounterShape.ts (MtgLifeCounterShapeProps = {w, h, value}, the standard TLGlobalShapePropsMap augmentation) and apps/tabletop/src/client/shapes/MtgLifeCounterShapeUtil.tsx (BaseBoxShapeUtil, no interaction hooks”
Holds
MtgLifeCounterShapeUtil is in apps/tabletop/src/client/shapes
Minted at seat-join time in tableFurniture.ts's ensurePlayerArea, on the name row, far right, via lifeCounterPosition()/LIFE_COUNTER_W/ LIFE_COUNTER_H in cardLayout.ts, starting at value: 40.
ensurePlayerArea depends on lifeCounterPosition
Holds
Read Oct 19
A fifth custom shape type, mtg-title (apps/tabletop/src/shared/mtgTitleShape.ts, props {w, h, text}; apps/tabletop/src/client/shapes/MtgTitleShapeUtil.tsx, BaseBoxShapeUtil<MtgTitleShape>), replaces the locked stock text seat-name label in tableFurniture.ts's ensurePlayerArea.
3 rules from this sentence
MtgTitleShape is in apps/tabletop/src/shared
Holds
MtgTitleShapeUtil is in apps/tabletop/src/client/shapes
checks “apps/tabletop/src/client/shapes/MtgTitleShapeUtil.tsx, BaseBoxShapeUtil<MtgTitleShape>), replaces the locked stock text seat-name label in tableFurniture.ts's ensurePlayerArea.”
Holds
Read Oct 19
It is the second instance of the life-counter pattern — a locked shape whose component() hosts a live DOM control writing to its own props — and it validates, on a real second shape, the two findings the life counter section records as load-bearing:
MtgTitleShapeUtil has a component method
Holds
Read Oct 19
A third custom shape type, mtg-counter (apps/tabletop/src/shared/mtgCounterShape.ts, props {w, h, text}; apps/tabletop/src/client/shapes/MtgCounterShapeUtil.tsx, BaseBoxShapeUtil<MtgCounterShape>): an unlocked, draggable disc with free editable text, blank by default.
No onClick — text editing is tldraw's stock double-click-to-edit (canEdit(): true), specifically so this util never triggers the PointingShape selection-deferral quirk.
MtgCounterShapeUtil has a canEdit method
Holds
Read Oct 19
isAspectRatioLocked(): true keeps the box square, which is what makes border-radius: 50% draw a circle rather than an ellipse.
MtgCounterShapeUtil has an isAspectRatioLocked method
Holds
Read Oct 19
evictCounters reparents the counters to the page and editor.animateShapes them to spots from findOpenSpotsNearZoneEdge — a new pure-geometry seam (apps/tabletop/src/client/shapes/openSpotNearZoneEdge.ts, no Editor, unit-tested in test/openSpotNearZoneEdge.test.ts): nearest zone edge to where the card entered, slots alternating outward (+1, −1, +2, −2…), "occupied" considering only mtg-card/mtg-counter bounds — furniture is fair ground to sit on.
findOpenSpotsNearZoneEdge is in apps/tabletop/src/client/shapes
Holds
Read Oct 19
zoneAt() was refactored to return the full ZoneHit ({id, zone}) instead of just the zone string, because eviction needs the zone shape's page bounds.
zoneAt depends on ZoneHit
Holds
Read Oct 19
apps/tabletop/src/client/shapes/MtgCounterTool.ts, a StateNode with id "mtg-counter": click-to-place one counter, then back to the select tool.
2 rules from this sentence
MtgCounterTool is in apps/tabletop/src/client/shapes
checks “apps/tabletop/src/client/shapes/MtgCounterTool.ts, a StateNode with id "mtg-counter":”
Holds
Read Oct 19
MtgCardShapeUtil's canReceiveNewChildrenOfType/canRemoveChildrenOfType are now keyed on PASSENGER_TYPES = new Set(["mtg-counter", "note"]) instead of a bare type === "mtg-counter" check, and evictCounters is renamed evictPassengers.
2 rules from this sentence
MtgCardShapeUtil has canReceiveNewChildrenOfType
Holds
MtgCardShapeUtil has canRemoveChildrenOfType
Holds
Read Oct 19
tapPartial (the pivot solve, previously a private method on MtgCardShapeUtil) moved to apps/tabletop/src/client/shapes/cardTap.ts as a standalone pure function — verified pure during review (reads only shape.rotation/shape.props.{w,h}, the module-level TAP_ANGLE, and the imported Vec helper) — no behavior change.
tapPartial is in apps/tabletop/src/client/shapes
Holds
Read Oct 19
Three changes, only the second and third of which are this owner's territory (the first — owner/isCommander becoming validated MtgCardShapeProps fields — is a fact the shape carries, not a mechanic; it grants no capability and adds no hook):
2 rules from this sentence
MtgCardShapeProps has an owner field
Holds
MtgCardShapeProps has an isCommander field
Holds
Read Oct 19
The mtgCardShape() builder (apps/tabletop/src/server/tableFurniture.ts, next to the existing zoneShape() helper): both mint seams now call this instead of writing their own store.put({...} as any) literal.
2 rules from this sentence
mtgCardShape is in apps/tabletop/src/server
checks “The mtgCardShape() builder** (apps/tabletop/src/server/tableFurniture.ts”
Holds
zoneShape is in apps/tabletop/src/server
checks “The mtgCardShape() builder (apps/tabletop/src/server/tableFurniture.ts, next to the existing zoneShape() helper):”
Holds
Read Oct 19
The fix is one function, apps/tabletop/src/client/clearStaleSelectionOnPointerDown.ts, registered once at Tldraw mount time (TablePage.tsx's onTldrawMount, renamed from aimCameraAtTheTable since it now does two things at mount, not one):
2 rules from this sentence
clearStaleSelectionOnPointerDown is in apps/tabletop/src/client
checks “The fix is one function, apps/tabletop/src/client/clearStaleSelectionOnPointerDown.ts”
Holds
onTldrawMount depends on clearStaleSelectionOnPointerDown
Holds
Read Oct 19
Four sibling files, each taking editor: Editor and the relevant shape(s) as explicit parameters instead of reading this.editor (the same pattern cardTap.ts's tapPartial already used, ticket 17): cardRender.tsx — component()'s JSX body (CardFace({shape})), getIndicatorPath's body (cardIndicatorPath(shape)), and the tap catch-up useLayoutEffect.
MtgCardShapeUtil has a component method
Holds
Read Oct 19
cardRender.tsx — component()'s JSX body (CardFace({shape})), getIndicatorPath's body (cardIndicatorPath(shape)), and the tap catch-up useLayoutEffect.
MtgCardShapeUtil has getIndicatorPath, and MtgCardShapeUtil depends on cardIndicatorPath
Holds
Read Oct 19
cardTapClick.ts — onClick's full body (handleCardClick(editor, shape)), including ticket 16's queueMicrotask undo-coalescing trick for multi-untap propagation, preserved verbatim with its ordering-hazard comment.
MtgCardShapeUtil has onClick, and MtgCardShapeUtil depends on handleCardClick
Holds
Read Oct 19
cardPassengers.ts — PASSENGER_TYPES, the two can* gates (canReceivePassenger/canRemovePassenger), and onDragShapesIn/onDragShapesOut's bodies (handleDragShapesIn/handleDragShapesOut(editor, ...)), including the rotation-zeroing math for reparentShapes' page-rotation-preservation quirk.
2 rules from this sentence
MtgCardShapeUtil has onDragShapesIn, and MtgCardShapeUtil depends on handleDragShapesIn
Holds
MtgCardShapeUtil has onDragShapesOut, and MtgCardShapeUtil depends on handleDragShapesOut
Holds
Read Oct 19
cardZoneEntry.ts — NON_BATTLEFIELD_ZONES, onTranslateEnd's body (handleTranslateEnd(editor, current)), and its two former-private helpers zoneAt/ evictPassengers, now module-level functions taking editor explicitly.
MtgCardShapeUtil has an onTranslateEnd method, and MtgCardShapeUtil depends on handleTranslateEnd
Holds
Read Oct 19
apps/tabletop/src/client/closeContextMenuBeforeOutsideClick.ts (new) exports closeContextMenuBeforeOutsideClick(): () => void, registered from onTldrawMount in TablePage.tsx alongside clearStaleSelectionOnPointerDown(editor):
2 rules from this sentence
onTldrawMount depends on closeContextMenuBeforeOutsideClick
checks “registered from onTldrawMount in TablePage.tsx”
Holds
closeContextMenuBeforeOutsideClick is in apps/tabletop/src/client
Since 2026-08-20, also exports RoomEntry.stackCardCount(owner) — replaces the deleted PlayerArea.stackCount monotonic counter with a live count, taken fresh on every card.played arrival, of how many mtg-card shapes owned by owner currently have (x, y) inside stackBounds() (cardLayout.ts), read via this.room.getCurrentSnapshot().documents.
RoomEntry has a stackCardCount method
Holds
Read Oct 19
ticket 13: zoneShape() now builds real mtg-zone shape records (type: "mtg-zone", props: { w, h, zone, seatId, label }, always isLocked: true) instead of stock geo/image shapes tagged with meta.zone; the old RegionStyle/DEFAULT_REGION_STYLE/PLAYMAT_REGION_STYLE styling machinery was deleted (visual treatment now lives in MtgZoneShapeUtil.component()).
MtgZoneShapeUtil has a component method
Holds
Read Oct 19
ZoneShapeArgs/zoneShape() gained an imageUrl?: string | null field threaded from ensurePlayerArea's look.playmatImageUrl into the mtg-zone record's own props.imageUrl — the old matImageId/AssetRecordType/ imageAsset()/imageShape() sequence for the playmat was deleted outright, not just refactored.
ZoneShapeArgs has an imageUrl field
Holds
Read Oct 19
Since table-layout ticket 18 (2026-08-09), also home to mtgCardShape(args: MtgCardShapeArgs) (next to zoneShape()) — the single place every required mtg-card prop is listed when building a shape record; called by both cardArrival.ts and seatJoined.ts instead of each writing its own store.put literal. See watch point 15.
mtgCardShape depends on MtgCardShapeArgs
Holds
Read Oct 19
MtgCardShapeArgs.cardSource is required with no default, so the compiler finds every mint site.
MtgCardShapeArgs has cardSource
Holds
Read Oct 19
Furniture is now mtg-zone, a genuine custom shape type (ticket 13, landed 2026-08-08; apps/tabletop/src/shared/mtgZoneShape.ts + apps/tabletop/src/client/shapes/ MtgZoneShapeUtil.tsx) — no longer stock, locked geo/image shapes stamped with a bare meta.zone string.
2 rules from this sentence
MtgZoneShapeUtil is in apps/tabletop/src/client/shapes
zoneAt() in MtgCardShapeUtil.tsx now filters candidate.type === "mtg-zone" and reads the validated candidate.props.zone, instead of scanning every shape for a truthy meta.zone.
zoneAt is in apps/tabletop/src/client/shapes
Holds
Read Oct 19
ensurePlayerArea (tableFurniture.ts) draws a locked mtg-zone with zone: "command" per seat, no interaction hooks; cardLayout.ts guarantees a 20-unit gap between every pair of zone boxes — since table-layout ticket 14 ("the square", 5eeac70), across all four compass seats AND the centered Stack — asserted pairwise in test/cardLayout.test.ts — see watch point 8.
ensurePlayerArea is in apps/tabletop/src/server
Holds
Read Oct 19
Zone heights changed 2026-08-09 (zone-label-band, 0d61890) without touching detection. Every card-holding zone now reserves ZONE_LABEL_BAND (40, exported from shared/mtgZoneShape.ts) of label headroom: library/command/exile are 278, graveyard 356.
2 rules from this sentence
ZONE_LABEL_BAND is in apps/tabletop/src/shared
checks “Zone heights changed 2026-08-09 (zone-label-band, 0d61890) without touching detection. Every card-holding zone now reserves ZONE_LABEL_BAND (40, exported from shared/mtgZoneShape.ts) of label headroom:”
Holds
ZONE_LABEL_BAND is exported
checks “ZONE_LABEL_BAND (40, exported from shared/mtgZoneShape.ts)”
Holds
Read Oct 19
Second store-level store.listen() consumer of this owner's gesture detection, and the first scoped to {source: "remote"} rather than {source: "user"}.apps/tabletop/src/client/ useCardArrivalSpans.ts (also fleet-is-observable's territory) already listened for new mtg-card shapes arriving;
useCardArrivalSpans is in apps/tabletop/src/client
Holds
Read Oct 19
So anything that reparents shapes (tldraw's own group/frame machinery, future features, undo) is silently also attach/detach for counters, and the drag hooks on MtgCardShapeUtil (canReceiveNewChildrenOfType/canRemoveChildrenOfType/onDragShapesIn/ onDragShapesOut) are the mediating surface.
4 rules from this sentence
MtgCardShapeUtil has canReceiveNewChildrenOfType
Holds
MtgCardShapeUtil has canRemoveChildrenOfType
Holds
MtgCardShapeUtil has onDragShapesIn
Holds
MtgCardShapeUtil has onDragShapesOut
Holds
Read Oct 19
Counter eviction on battlefield exit rides inside MtgCardShapeUtil.onTranslateEnd's zone-change branch — changing the zone-entry debounce or the NON_BATTLEFIELD_ZONES set changes when counters detach.
MtgCardShapeUtil has onTranslateEnd
Holds
Read Oct 19
One centralized listener, apps/tabletop/src/client/clearStaleSelectionOnPointerDown.ts (registered at Tldraw mount, TablePage.tsx's onTldrawMount), now clears a stale selection on every pointer_down whenever the hit shape isn't already selected — closing this watch point for every shape type, present and future, without a per-type hook.
2 rules from this sentence
onTldrawMount depends on clearStaleSelectionOnPointerDown
checks “One centralized listener, apps/tabletop/src/client/clearStaleSelectionOnPointerDown.ts (registered at Tldraw mount, TablePage.tsx's onTldrawMount), now clears a stale selection on every pointer_down whenever the hit shape isn't already selected”
Holds
clearStaleSelectionOnPointerDown is in apps/tabletop/src/client
Item 4 is conditional on the component's content being interactive — NOT on the shape being unlocked: ticket 13's mtg-zone generalized steps 1-3 cleanly (see architecture.md) but didn't need step 4 at all, because nothing ever clicks a zone — MtgZoneShapeUtil.component() renders a plain <div> with no .tl-image-container treatment and that's correct, not an oversight.
MtgZoneShapeUtil has a component method
Holds
Read Oct 19
Fixed by extracting mtgCardShape(args: MtgCardShapeArgs) in tableFurniture.ts (next to the existing zoneShape() helper): the one place that lists every required mtg-card prop, called by both cardArrival.ts and seatJoined.ts instead of each writing its own literal.
mtgCardShape depends on MtgCardShapeArgs
Holds
Read Oct 19
ZoneHit grew a field to make this possible: topmostZoneAt's return type is now {id, zone, seatId}, not {id, zone} — seatId comes straight off the winning mtg-zone candidate's props.seatId and is threaded through to both callers (zoneAt()'s drag-settle check ignores the new field; armedZoneIdSignal's live-drag check is what actually reads it).
2 rules from this sentence
ZoneHit has a seatId field
checks “ZoneHit grew a field to make this possible”
Holds
zoneAt depends on topmostZoneAt
checks “topmostZoneAt's return type is now {id, zone, seatId}, not {id, zone} — seatId comes straight off the winning mtg-zone candidate's props.seatId and is threaded through to both callers (zoneAt()'s drag-settle check ignores the new field; armedZoneIdSignal's live-drag check is what actually reads it).”
Holds
Read Oct 19
swallowCard (cardSwallow.ts) has to call evictPassengers (now exported from cardZoneEntry.ts, was private) itself before animating the swallow, or a counter/note riding the card would be silently deleted with its host, orphaned, or left parented to a deleted shape — none of which the skipped handleTranslateEnd branch was ever going to catch on this path.
evictPassengers is exported
Holds
Read Oct 19
This is now a second, independent implementation of "evict a card's passengers," and the two are deliberately not shared.evictPassengers (client-side, cardZoneEntry.ts) spot-finds open positions near a zone's edge and detaches to the page — appropriate when the card is landing somewhere else on the table (graveyard/exile/library) and a passenger needs a place to land too.
evictPassengers is in apps/tabletop/src/client
Holds
Read Oct 19
twoFaced: boolean — whether the card has a separate back image (drives the flip button)
CardDefinition has twoFaced
Holds
Read Oct 19
cardTypes: string[] — the union of every face's/part's types (e.g. ["Legendary","Creature","Planeswalker"])
CardDefinition has cardTypes
Holds
Read Oct 19
Plus cardSource?: "scryfall" | "archidekt-custom" — optional, absent means scryfall, always read through cardSourceOf(card);
CardDefinition has cardSource
Holds
Read Oct 19
name, cardDefinitionId (a Scryfall printing id for scryfall cards, a UUIDv5 for custom cards; renamed from scryfallId in 0a6c52dd), multiverseid?, oracleCardName, colorIdentity, set.
6 rules from this sentence
CardDefinition has name
Holds
CardDefinition has cardDefinitionId
Holds
CardDefinition has multiverseid
Holds
CardDefinition has oracleCardName
Holds
CardDefinition has colorIdentity
Holds
CardDefinition has set
Holds
Read Oct 19
currentFace: "front" | "back" — which face is currently showing
GameCard has currentFace
Holds
Read Oct 19
Persisted version also stores currentFace.
PersistedGameCard has currentFace
Holds
Read Oct 19
ArchidektDeckToDeckAdapter.convertCustomCard reads the deck API's customCards array (types in archidektTypes.ts, ArchidektCustomCard).
ArchidektDeckToDeckAdapter depends on ArchidektCustomCard
Holds
Read Oct 19
twoFaced = multiFace && isDoubleSidedLayout(layout) — only genuinely double-sided layouts
ArchidektDeckToDeckAdapter depends on isDoubleSidedLayout
Holds
Read Oct 19
twoFaced = isDoubleSidedLayout(mtgjsonCard.layout) (shared with Archidekt)
MtgjsonDeckAdapter depends on isDoubleSidedLayout
Holds
Read Oct 19
wraps card in formatFlippingContainer() with flip animation structure
formatCardContainer depends on formatFlippingContainer
Holds
Read Oct 19
Signature is (gameCard: GameCard, flipRequest: FlipRequest).
formatFlippingContainer depends on GameCard
Holds
Read Oct 19
getCardImageUrl(card: CardDefinition, format, face) — the one views call.
getCardImageUrl depends on CardDefinition
Holds
Read Oct 19
CardDefinition carries two optional fields for this: imageUris?: CardImageUris (front/only face) and backImageUris?: CardImageUris (present only when twoFaced).
2 rules from this sentence
CardDefinition has an imageUris field
Holds
CardDefinition has a backImageUris field
checks “backImageUris?: CardImageUris”
Holds
Read Oct 19
CardImageUris = Partial<Record<ImageFormat, string>>, storing only the formats the app uses (normal, large, png, art_crop).
CardImageUris depends on ImageFormat
Holds
Read Oct 19
A new port fetches Scryfall image URLs by scryfallId: CardImagesPort / FetchedCardImages ({front, back?}) — types.ts
enrichDeckWithImages(deck, port) — collects unique cardDefinitionIds, passes them to the port as Scryfall ids, fetches, attaches imageUris to every card and backImageUris only to twoFaced cards.
enrichDeckWithImages depends on CardImagesPort
Holds
Read Oct 19
Archidekt adapter (optional injected imagesPort, wired in server.ts + download-deck script), and the fetch-mtgjson-precons script after conversion.
ArchidektDeckToDeckAdapter depends on CardImagesPort
Holds
Read Oct 19
The MTGJSON adapter's convertMtgjsonToDeck stays synchronous (enrichment is a separate post-pass).
MtgjsonDeckAdapter has convertMtgjsonToDeck
Holds
Read Oct 19
Image URLs are stored on the card (imageUris/backImageUris), fetched from Scryfall at ingestion via src/port-card-images/ (ScryfallCardImagesGateway → POST /cards/collection). getCardImageUrl(card, format, face) prefers the stored URL; constructCardImageUrl(scryfallId, format, face) is the fallback.
2 rules from this sentence
ScryfallCardImagesGateway is in apps/shuffler/src/port-card-images
checks “fetched from Scryfall at ingestion via src/port-card-images/ (ScryfallCardImagesGateway”
Holds
getCardImageUrl depends on constructCardImageUrl
checks “getCardImageUrl(card, format, face) prefers the stored URL; constructCardImageUrl(scryfallId, format, face) is the fallback”
Holds
Read Oct 19
All outbound Scryfall calls go through fetchScryfall() in src/scryfall-http.ts, which sets it; /proxy-image shipped without one and 400'd every card copy until JES-136.
fetchScryfall is in apps/shuffler/src
Holds
Read Oct 19
If Scryfall's path scheme changes, the stored URLs still work (verbatim from Scryfall); only the fallback constructCardImageUrl() in src/domain-types.ts would need updating.
constructCardImageUrl is in apps/shuffler/src
Holds
Read Oct 19
convertCustomCard in ArchidektDeckToDeckAdapter.ts builds them from the deck API's customCards;
ArchidektDeckToDeckAdapter has convertCustomCard
Holds
Read Oct 19
convertCustomCard in ArchidektDeckToDeckAdapter.ts builds them from the deck API's customCards; id is uuidv5("archidekt-custom:<id>") (src/uuidv5.ts); two-faced via isDoubleSidedCustomCard(hasBack, backImageUrl).
ArchidektDeckToDeckAdapter depends on isDoubleSidedCustomCard
Holds
Read Oct 19
Every Scryfall-specific path must branch on cardSourceOf(card): getCardImageUrl, enrichDeckWithImages, /proxy-image, the Gatherer link.
2 rules from this sentence
getCardImageUrl depends on cardSourceOf
Holds
enrichDeckWithImages depends on cardSourceOf
Holds
Read Oct 19
Both adapters share src/port-deck-retrieval/twoFacedLayouts.ts (isDoubleSidedLayout() / DOUBLE_SIDED_LAYOUTS) — the single source of truth for what's flippable
2 rules from this sentence
isDoubleSidedLayout is in apps/shuffler/src/port-deck-retrieval
Holds
DOUBLE_SIDED_LAYOUTS is in apps/shuffler/src/port-deck-retrieval
isDoubleSidedCustomCard is in apps/shuffler/src/port-deck-retrieval
Holds
Read Oct 19
formatCardContainer() branches on card.twoFaced to decide between simple <img> and formatFlippingContainer()
formatCardContainer depends on formatFlippingContainer
Holds
Read Oct 19
PersistedGameCard includes currentFace
PersistedGameCard has currentFace
Holds
Read Oct 19
GameState.flipCard() mutates currentFace on the GameCard
3 rules from this sentence
GameState has a flipCard method
checks “GameState.flipCard()”
Holds
GameState depends on GameCard
Holds
GameCard has currentFace
checks “mutates currentFace on the GameCard”
Holds
Read Oct 19
src/port-tabletop/types.tsbuildCardPlayedEvent is the ONE door where a GameCard is serialized for the table.
buildCardPlayedEvent depends on GameCard
Holds
Read Oct 19
As of cards-come-and-go ticket 08 (2026-08-23), buildCardPlayedEvent, buildCardPlayedFaceDownEvent, and the new buildCardDiscardedEvent all share one private helper, cardFaceFields(gameCard), for the face/frontImageUrl/backImageUrl computation — see watch point 19 and 25.
3 rules from this sentence
buildCardPlayedEvent depends on cardFaceFields
Holds
buildCardPlayedFaceDownEvent depends on cardFaceFields
Holds
buildCardDiscardedEvent depends on cardFaceFields
Holds
Read Oct 19
CardDefinition changes: CardDefinition is intentionally minimal — identity fields, twoFaced, cardTypes, plus the optional imageUris/backImageUris (image URLs) and cardSource (absent = scryfall; read via cardSourceOf, never card.cardSource directly).
5 rules from this sentence
CardDefinition has a twoFaced field
checks “CardDefinition is intentionally minimal — identity fields, twoFaced, cardTypes, plus the optional imageUris/backImageUris (image URLs) and cardSource (absent = scryfall”
Holds
CardDefinition has a cardTypes field
checks “CardDefinition is intentionally minimal — identity fields, twoFaced, cardTypes, plus the optional imageUris/backImageUris (image URLs) and cardSource (absent = scryfall”
Holds
CardDefinition has an imageUris field
Holds
CardDefinition has a backImageUris field
checks “CardDefinition is intentionally minimal — identity fields, twoFaced, cardTypes, plus the optional imageUris/backImageUris (image URLs) and cardSource (absent = scryfall”
Holds
CardDefinition has a cardSource field
Holds
Read Oct 19
Face-down is modeled only on the Tabletop — and both the gesture and both renderings are now built. Ticket 02 (2026-08-07, c956949) gave it a home: faceDown: boolean in the mtg-card shape's props.
MtgCardShapeProps has faceDown
Holds
Read Oct 19
Physics ticket 17 (2026-08-09, eb24a4f) built the rest: the shape gained cardBackImageUrl: string | null, baked at mint from playerArea.cardBackImageUrl (same game-constant argument), and the unsleeved faceDown branch now renders it as an <img> — falling back to a flat #3a3a3a rectangle when no card back was baked in (a seat that predates the prop, or redeploy-wiped seat memory).
MtgCardShapeProps has cardBackImageUrl
Holds
Read Oct 19
This owner's invariant held and stays binding: backImageUrl: null ⇔ no printed back exists is untouched; sleeveColor is its own nullable prop, mirroring backImageUrl.
2 rules from this sentence
MtgCardShapeProps has sleeveColor
checks “sleeveColor is its own nullable prop”
Holds
MtgCardShapeProps has backImageUrl
checks “mirroring backImageUrl”
Holds
Read Oct 19
GameState.newGame() gained two trailing optional params (sleeveColor, playmatImagePath), snapshotted from prep.sleeveColor/prep.playmatImagePath at both /start-game and /restart-game call sites in src/app.ts, stored as new optional fields directly on GameState/PersistedGameState, and rendered: formatLibraryStack() takes an optional third sleeveColor param and (via new helper formatLibraryCardBack()) renders a sleeved back as a flat background-color rectangle instead of the CARD_BACK<img>;
5 rules from this sentence
GameState has a newGame method
checks “GameState.newGame() gained two trailing optional params”
Holds
GameState has a sleeveColor field
checks “GameState.newGame() gained two trailing optional params (sleeveColor, playmatImagePath), snapshotted from prep.sleeveColor/prep.playmatImagePath at both /start-game and /restart-game call sites in src/app.ts, stored as new optional fields directly on GameState/PersistedGameState”
Holds
GameState has a playmatImagePath field
checks “GameState.newGame() gained two trailing optional params (sleeveColor, playmatImagePath), snapshotted from prep.sleeveColor/prep.playmatImagePath at both /start-game and /restart-game call sites in src/app.ts, stored as new optional fields directly on GameState/PersistedGameState”
Holds
PersistedGameState has a sleeveColor field
checks “GameState.newGame() gained two trailing optional params (sleeveColor, playmatImagePath), snapshotted from prep.sleeveColor/prep.playmatImagePath at both /start-game and /restart-game call sites in src/app.ts, stored as new optional fields directly on GameState/PersistedGameState”
Holds
PersistedGameState has a playmatImagePath field
checks “GameState.newGame() gained two trailing optional params (sleeveColor, playmatImagePath), snapshotted from prep.sleeveColor/prep.playmatImagePath at both /start-game and /restart-game call sites in src/app.ts, stored as new optional fields directly on GameState/PersistedGameState”
Holds
Read Oct 19
formatLibraryStack() takes an optional third sleeveColor param and (via new helper formatLibraryCardBack()) renders a sleeved back as a flat background-color rectangle instead of the CARD_BACK<img>;
formatLibraryStack depends on formatLibraryCardBack
Holds
Read Oct 19
mtg-zone props gained sleeveColor: string | null (set only on a sleeved seat's library zone), MtgZoneShapeUtil renders a sleeved pile as the bare sleeve rectangle, and both renderings share LIBRARY_PILE_INSET = 12 from apps/tabletop/src/shared/mtgZoneShape.ts.
2 rules from this sentence
LIBRARY_PILE_INSET is in apps/tabletop/src/shared
checks “both renderings share LIBRARY_PILE_INSET = 12 from apps/tabletop/src/shared/mtgZoneShape.ts”
Holds
MtgZoneShapeUtil depends on LIBRARY_PILE_INSET
checks “sleeveColor: string | null (set only on a sleeved seat's library zone), MtgZoneShapeUtil renders a sleeved pile as the bare sleeve rectangle, and both renderings share LIBRARY_PILE_INSET = 12 from apps/tabletop/src/shared/mtgZoneShape.ts.”
Holds
Read Oct 19
Shuffler — tracks currentFace on each GameCard, renders flip buttons, sends the current face when a card leaves its boundary.
GameCard has currentFace
Holds
Read Oct 19
card.played's scaffolding imageUrl field is gone, replaced by frontImageUrl + backImageUrl: string | null (buildCardPlayedEvent in apps/shuffler/src/port-tabletop/types.ts).
buildCardPlayedEvent is in apps/shuffler/src/port-tabletop
Holds
Read Oct 19
A card entering the library resets both axes to face:'front', faceDown:falsein MtgCardShapeUtil.onTranslateEnd.
MtgCardShapeUtil has onTranslateEnd
Holds
Read Oct 19
we store almost no card text.** CardDefinition carries only identity/grouping data (name, cardDefinitionId, twoFaced, cardTypes, colorIdentity, set, …).
6 rules from this sentence
CardDefinition has name
Holds
CardDefinition has cardDefinitionId
Holds
CardDefinition has twoFaced
Holds
CardDefinition has cardTypes
Holds
CardDefinition has colorIdentity
Holds
CardDefinition has set
Holds
Read Oct 19
Aspect State mutation, Details GameState.flipCard() in src/GameState.ts
GameState has a flipCard method
Holds
Read Oct 19
Aspect View rendering, Details formatFlippingContainer(gameCard, flipRequest) in src/view/common/shared-components.ts FlipRequest says which page is asking (game POST vs prep GET)
formatFlippingContainer is in apps/shuffler/src/view/common
Aspect Image URLs, Details getCardImageUrl(card, format, face) (prefers stored) + constructCardImageUrl(scryfallId, format, face) (Scryfall-only fallback) in src/domain-types.ts stored in CardDefinition.imageUris / backImageUris A non-scryfall card never reaches constructCardImageUrl
Aspect Persistence, Details SqliteCardRepositoryAdapter keys on card_definition_id and stores card_types , image_uris , back_image_uris as JSON, plus card_source (nullable no back_face column) PersistedGameCard.currentFace
PersistedGameCard has currentFace
Holds
Read Oct 19
CardDefinition or type changes: Does it add/remove/rename fields on CardDefinition? There is no CardFace/backFace (removed in f76b49c); don't re-add per-face card text. Card identity is cardDefinitionId — don't reintroduce scryfallId except at Scryfall edges. Also check if PersistedGameCard or hydration logic needs updating.
CardDefinition has cardDefinitionId
Holds
Read Oct 19
Game state or persistence changes: currentFace is persisted in PersistedGameCard. Version migrations must preserve or default it. The flipCard() method on GameState must stay consistent with the persisted format.
2 rules from this sentence
PersistedGameCard has currentFace
checks “currentFace is persisted in PersistedGameCard”
Holds
GameState has a flipCard method
checks “The flipCard() method on GameState”
Holds
317 rules from 22 docs
See these where the change is. The browser extension puts a pull request's doc findings, and a diagram of what it changed, on the GitHub page itself, so the code and what your docs say about it are side by side.