Skip to content

Add design spec for pile-based tabletop rework - #88

Merged
jordanwallwork merged 39 commits into
masterfrom
claude/vtt-implementation-review-mp0t8q
Jul 14, 2026
Merged

Add design spec for pile-based tabletop rework#88
jordanwallwork merged 39 commits into
masterfrom
claude/vtt-implementation-review-mp0t8q

Conversation

@jordanwallwork

Copy link
Copy Markdown
Owner

Records the full decision set from the design review: pile-based state
model replacing stack zones, four region zone types with a behaviour
strategy, invariant-based normalization, unified drop pipeline, pure
interaction reducer, transaction history, and the single pure-function
testing seam.

Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01TvoMCjLbUJGeo7nGQuTd58

claude added 29 commits July 11, 2026 08:16
Records the full decision set from the design review: pile-based state
model replacing stack zones, four region zone types with a behaviour
strategy, invariant-based normalization, unified drop pipeline, pure
interaction reducer, transaction history, and the single pure-function
testing seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvoMCjLbUJGeo7nGQuTd58
…history, drag reducer

Foundation slice of the pile-based tabletop rework (SPEC.md). Establishes
the single pure-function testing seam: state types with capability-flagged
templates, spawn/move/selection operations, the full-sweep normalize pass
(referential integrity wired; throws in dev, repairs in prod), the
transaction-based history module, and the drag state machine as a pure
reducer driven by synthetic pointer sequences in tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3zpPk64cGiAuwQF9fAGHd
Adds the v2 shell components (canvas, pile renderer, card face, sidebar,
toolbar) as thin shells over the pure v2 modules, and a persisted dev
toggle on the tabletop page. v1 remains the default engine and is
untouched — this is the expand step of the feature-level expand–contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3zpPk64cGiAuwQF9fAGHd
Reading it during component init would make client hydration disagree
with the server-rendered markup, which is always v1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3zpPk64cGiAuwQF9fAGHd
Template drops now resolve through a shared drop resolver that spawns only
the instances not already on the table (mergeData identity for data-source
cards, count identity otherwise, carried over from v1). Piles can be removed
wholesale or per-template, and the drag reducer removes a pile released over
the sidebar inside the drag transaction so the gesture stays one undo step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyE4uYNHRJ9G7HwrqaXXee
…count badge

Canvas drops route through the shared drop resolver so re-dropping a
component spawns only its missing instances. The sidebar shows remaining
counts, highlights as a removal target while a pile drag hovers it, and
gains a per-component remove-all button; multi-card piles render a count
badge (the whole-pile handle arrives with ticket 03).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DyE4uYNHRJ9G7HwrqaXXee
…lution

Plain drag on a multi-card pile splits the top card into a zoneless
single-card pile at drag start; badge or Alt grabs move the whole pile.
Drops resolve through the shared resolver: a pile under the pointer
merges (dropped pile on top, each card keeping rotation and face state)
when unlocked and both sides are mergeable, otherwise the pile is placed
where it visually sits. The empty-pile invariant goes live in normalize.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017L4eB2AkGsmEziYk8KiiZb
…peek

The badge grabs the entire pile, plain drags pass the Alt modifier
through to the reducer, and cards lower in a pile whose rotated AABB
exceeds the top card's render behind it so orientation and mixed sizes
stay visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017L4eB2AkGsmEziYk8KiiZb
…der, ctrl-click selection

- flipPile (reverse order + toggle faces), flipTopCard, rotatePile,
  shufflePile (injectable rng), setPileLocked, lowerPile in operations
- actions.ts: selection-wide F/R/S dispatch with applicability filters,
  pileActions context-menu table, applyPileAction dispatcher
- reducer: Ctrl/Cmd+click toggles piles in a multi-selection; locked
  piles refuse drag and split but stay click-selectable (SPEC story 47)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dqwb4Loo5ejKLVUqaqKkWp
…k indicator, flip transition

- TabletopV2 dispatches selection-wide F/R/S as single commits, skipping
  inert selections so no-ops never enter undo history
- PileContextMenu maps the pure pileActions table onto the shared
  ContextMenu component; right-click selects the pile first
- PileRenderer forwards Ctrl/Cmd to the reducer, shows a lock indicator,
  and renders the top card as a 3D front/back flip transition
- CardFace accepts a side override so both flip faces can render

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dqwb4Loo5ejKLVUqaqKkWp
…rag, multi-drop resolution

Marquee mode rubber-bands unlocked root piles with rotation-aware
(separating-axis) hit-testing; grabbing any pile of a multi-selection
drags the whole selection by one world delta; the shared drop resolver
gains a piles payload that merges each mergeable pile into the target
under the pointer and places the rest, all as one undo step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VrA8vNJDEZTtAw8PC7Jo3
…ubber-band render, group drag styling

Background pointer-downs now feed the drag reducer (which owns the
deselect click too, replacing the canvas click handler); the live
marquee rectangle renders on the surface; dragged-pile styling and the
sidebar removal hit-test cover every pile of a group drag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VrA8vNJDEZTtAw8PC7Jo3
…te discipline, behaviour table, zone-aware drop resolution, zone-move/resize reducer modes

- zones.ts: freeform zone lifecycle (create/rename/resize/lock/delete-with-
  contents), corner-resize math with min-size clamp, findZoneAt hit-testing,
  and the zone behaviour table (freeform planDrop = at-point; the root region
  shares it; grid/spread/group placeholders until their tickets).
- geometry.ts: world-position helpers (zoneWorldOrigin/Rect, pileWorldCenter,
  world/local conversion) — the single conversion boundary per the SPEC's
  coordinate discipline; pile hit rects are now world-space.
- operations.ts: detachPileToRoot and placePile move piles across region
  boundaries converting coordinates there; splitTopCard spawns the drawn card
  at the source's world centre.
- drop.ts: the shared resolver targets zones (locked zones refuse drops and
  merges onto their piles), spawn/place plans carry the destination region,
  and movePileToZone replays 'Move to <zone>' as a literal drop.
- reducer.ts: zone-move mode (header drag, click-selects, sidebar deletes),
  zone-resize mode (transient frames inside the zone-edit session's
  transaction), zone-body marquee/click-select, world-space pile drags that
  detach at activation, marquee sweeping zone piles, and an inert table
  while a zone-edit session is open.
- store.svelte.ts: zone-edit sessions as single transactions (create+edit or
  edit; Done commits one undo step, Escape rolls the whole session back).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8gsyhg6cJRj9uC93AbyfW
…t toolbar, context menus, Move to zone

- ZoneRenderer: dashed region chrome, always-visible header tab as the only
  move handle, pile count and lock badges, body presses forwarded as
  background events (click selects the zone, drag marquees), edit-mode
  rename input + Done/Delete toolbar and corner resize handles.
- ZoneContextMenu driven by the pure zoneActions table (edit/lock/delete;
  locked zones offer only Unlock).
- Canvas context menu with Add Zone → create + edit as one transactional
  session (Done = one undo step; Escape removes the zone again).
- PileContextMenu gains a Move to <zone> submenu running through the shared
  drop resolver; PileRenderer double-click selects the containing zone.
- TabletopV2 renders zones beneath root piles, treats a dragged zone as a
  sidebar-removal candidate, and gates keyboard shortcuts while a zone-edit
  session is open (Escape cancels the whole session).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8gsyhg6cJRj9uC93AbyfW
…layout invariants in normalize, insert-index drop plumbing, zone-wide actions

Spread zones are the one ordered region: creation with row/overlap defaults,
per-slot steps derived from each pile's own footprint (heterogeneous sizes),
point-derived insert indices threaded through DropPlacement/DropPlan into
placePile, behaviour-table shuffle with injectable random, and zone-wide
select-all/flip-all/rotate-all reusing the selection applicability rules.
Normalize re-establishes the splay (multi-card arrivals fan into single-card
piles preserving order and layering) and the layout on every commit.
… edit controls, zone-wide context menu actions and F/R/S on a selected zone
Grid is a region whose cells are derived positions, never stored indices:
a pile occupies whatever cell its centre falls in. Adds the grid behaviour
(planDrop snaps to the cell under the point, nearest free cell on collision,
overlap only when full; shuffle permutes which occupied cells the piles sit
on, preserving the occupied set), the cell math helpers, the setters for
cell width/height/columns, and snapGridToCells wired as a normalize
invariant. Cover the seam with scenario tests: snap on drop, nearest-free
search, merge-vs-nearest-free disambiguation, occupancy-preserving shuffle,
deck/die/token coexistence, and collision resolution in normalize.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UuWKb7HKF9VPtvr46XXs4
…ines

Add Grid joins the canvas menu; the zone edit toolbar gains cell
width/height/column inputs that re-snap contents live; the grid's cell
lattice renders beneath its piles. Centre a freshly created zone on the
click using its own default size, so grid's larger footprint lands centred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UuWKb7HKF9VPtvr46XXs4
…ointer disambiguation, resolver-derived insertion hint

Seams (leading edges + trailing edge) replace centre counting for the insert
index, so slots sit exactly where the user sees the boundaries — including
mid-drag gaps. Insertion bands flanking each seam make slotting and stacking
both reachable however tight the overlap, decided in the shared resolver;
drops on cards overhanging the zone edge resolve against their spread.
spreadInsertHint derives the indicator from the resolver's own plan so the
two can never disagree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FS9JhJ9cxES1dhEn4zpPhE
The interaction shell tracks the pointer and derives the insertion hint and
hover zone from the resolver; sidebar HTML5 drags derive the same on
dragover, with the dragged template reported through the api (dataTransfer
is sealed until drop). ZoneRenderer draws the slot indicator and the
drop-region highlight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FS9JhJ9cxES1dhEn4zpPhE
Dice become first-class table objects and the group zone gives them a
natural tray, all below the pure-function seam.

- Dice: rollPile sets a random result in [1, faces] on each die card; the
  `faces` capability drives it (never the component list). S dispatches per
  pile across a mixed selection — dice roll, multi-card piles shuffle — via
  shuffleOrRollPiles; F already skips dice (flippable: false). The pile menu
  gains a Roll action.
- Multi-dice spawn: a dice-set template resolves to a scatter of N loose
  single-die piles around the drop point (scatterAround), each passing
  through the hit region's own drop rules — no zone is ever created.
- Group zone: createGroupZone plus a real group behaviour — planDrop jitters
  position within the zone and hands back a rotation delta (applied at
  commit), shuffle re-scatters the contents, and onLeave snaps each card's
  rotation to the nearest 90 so cosmetic jitter is dropped while deliberate
  orientation survives.
- Wire the previously-inert onLeave hook: detachPileFromZone fires the source
  zone's onLeave before lifting a pile to the root, the single path both the
  drag detach and "Move to <zone>" route through.

Scenario-shaped seam tests cover roll bounds, scatter placement, the mixed
S dispatch, group jitter/re-scatter, and snap-to-90 on leave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tZCqBFAKSvB7hZX5c1xeY
- Add Group to the canvas context menu (createZoneAndEdit gains the group
  creator).
- Route the detach-pile drag mutation through detachPileFromZone so leaving a
  group snaps the scatter tilt.
- Bind S to shuffle-or-roll for pile selections, and add Roll to the pile
  context menu.
- Give the group zone a soft tint in the zone renderer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tZCqBFAKSvB7hZX5c1xeY
…parent, descendant-excluded hit-testing

Boards/mats become freeform container zones — the one spawn that creates a
zone. The drop resolver turns an isContainer template into a spawn-zone plan
(a region sized to physical scale, rendering the template artwork), and
createContainerZone builds it.

Zone nesting composes the table: reparentZone re-parents a zone under a
freeform container (or the root), rewriting stored coordinates so its world
origin — and everything nested inside it — stays visually stationary.
zoneNestTarget/findNestTarget resolve the target from a zone's own centre,
excluding the dragged zone and all its descendants so a parent can never nest
into its own child. findZoneAt, findPileAt, the marquee, and "Move to zone"
all walk the full nested tree (renderOrderedZoneIds), so piles drop onto
boards and into nested zones exactly as onto any freeform region.

The drag reducer resolves nesting at the end of a zone-header drag via a new
nest-zone mutation, inside the same transaction — so cancel/undo restore zone
membership as one step.

Seam tests cover nest/un-nest coordinate conversion, deep-nesting world math,
descendant-excluded hit-testing, board-spawn plan, and pile-on-board travel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ks7mgiRvLH6wqc1xdFMwbw
…rsive nested-zone rendering

ZoneBackground renders a board/mat's designed front artwork (scaled to the
zone's physical footprint) as a freeform container's background. ZoneRenderer
draws it beneath the piles and renders childZoneIds recursively via a
self-import, so nested zones position from their parent-local coordinates with
no world-space maths in the view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ks7mgiRvLH6wqc1xdFMwbw
…tings cache

convertZone swaps a populated zone's type in place — id, name, position,
lock, contents and any nesting preserved — caching the outgoing type's
settings (spread direction/overlap, grid cell dims/columns) and restoring or
defaulting the incoming type's. Re-placing contents stays with the existing
invariants: normalize splays a deck across a new spread and seats piles on a
new grid's cells, and the shared onLeave hook snaps rotations to 90° when
leaving a group. Freeform children un-nest to the root so a conversion never
orphans them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zPdUGfUUBTHLsEsbRjEJC
The zone edit toolbar gains a four-way type switch (freeform/grid/spread/
group). Converting is a transient frame of the edit session — normalize runs
immediately for feedback while the single history entry defers to Done, and
Escape reverts the type change with the rest of the session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zPdUGfUUBTHLsEsbRjEJC
…imation hints

Port v1's transient-hint animation mechanism onto the pile model as pure
planning functions plus the store's transient-hint orchestration.

Shuffle riffle (any multi-card pile): the new order is precomputed up front
(computeShuffledOrder) with an animation sample that always includes the old
and new top (pickShuffleAnimationCards), held as a deferred PendingShuffle and
committed as one undo step when the fan lands. Interruptions are safe:
requestShuffle finalises an in-flight shuffle before starting one on another
pile, and the store finalises a pending shuffle before any other commit,
transaction, or undo/redo — so the order is never lost. A batch pending keeps
a multi-pile S press to a single undo step.

Wave flip (spread/grid zones): planZoneFlip decides when a zone-wide flip
should ripple; the flip itself stays a single atomic commit and the wave is a
transient render hint (per-index stagger).

Seam tests cover order pre-computation and sampling, plan/apply, and
pending-shuffle finalisation on interrupt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EE8zogpKSVLXXtBWKSZtRj
Render the deferred-order animations. A shuffling pile hands its visuals to a
new v2 ShuffleAnimation overlay (fan out/in with a mid-flight z-swap so the new
top lands cleanly), driven by the store's pending-shuffle hint. PileRenderer
gains a flipDelay prop so a zone's wave-flip staggers each pile's flip
transition; ZoneRenderer derives the per-index delay from the zone-flip hint.

Route the triggers through the animated store methods: the pile context-menu
Shuffle and the S shortcut on a pure multi-card selection play the riffle; a
selection that also rolls dice stays an instant single commit (nothing to
animate). Zone Flip All (menu and F) ripples on spreads and grids.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EE8zogpKSVLXXtBWKSZtRj
Replace the naive pan/zoom placeholder on the v2 surface with a pure
viewport module and a thin reactive shell:

- viewport.ts: world↔screen transform, pointer-anchored zoom (keeps the
  world point under the cursor fixed, fixing v1's origin drift), pan
  accumulation, and fit-view framing over zones and loose root piles.
- viewport.svelte.ts: reactive shell wrapping the pure maths.
- reducer.ts: a `pan` mode (middle-button) that emits screen-space pan
  deltas routed to the viewport, never the store — no transaction, no
  history, works even during a zone-edit session.
- TabletopV2 wires wheel zoom (ctrl/cmd anchored, plain scroll pans),
  middle-drag pan, a fit-view toolbar button, and a default fit on load.

Seam tests cover zoom-about-point, fit-view bounding box, and pan
accumulation; reducer tests cover the middle-drag pan gesture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzLw2LYLd6NTzxNNxPNK51
claude and others added 10 commits July 12, 2026 15:55
Split the five v2 tabletop components that breached the 150-line
Single Responsibility rule into focused base + sub-components. Fixes #93.

ZoneRenderer (646) is now a slim base renderer that composes ZoneFrame,
ZoneHeaderTab, ZoneEditToolbar (with ZoneTypeSwitch + per-type
ZoneSpreadSettings/ZoneGridSettings), ZoneGridLines, ZoneInsertIndicator
and ZoneResizeHandles — resolving the "base + per-type sub-components"
and "prefer snippets/components over if-cascades" violations.

TabletopV2 (522) is now a thin shell; its keyboard shortcuts, context-menu
state and canvas/viewport/drag wiring moved into co-located modules
(tabletopKeyboard, tabletopMenus, tabletopCanvas) with TabletopCanvasView,
TabletopContextMenus and TabletopSurface presentational components, per the
"business logic in .svelte.ts modules" guideline.

ComponentSidebar (320) splits into SidebarHeader, SidebarComponentItem and
SidebarComponentDetails. PileRenderer (263) and ShuffleAnimation (162) share
a new FlipCard and split out PileFace and PileCountBadge.

Every resulting component is under 150 lines. svelte-check passes with no
errors; the 332 tabletop v2 unit tests are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ1FmqFLRZ21WxdJMvYmZY
groupBehavior.shuffleZone set every card in a pile to one identical
absolute rotation when re-scattering, destroying the per-card relative
orientation the spec requires to survive (stories 7 & 9). Apply the
scatter tilt as a per-pile delta anchored on the pile's first card
instead, matching how planDrop and onLeave already treat group rotation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019a4nNGmp5fw8X2m8JBAQyT
Remove duplicated code smells flagged in the tabletop v2 review:

- Add `freeformChildren(zone)` accessor in zones.ts and route the
  repeated `zone.type === 'freeform' && zone.childZoneIds` guard (and its
  parent-list splice variants) through it.
- Add a shared `transientNumberInput` helper for the numeric zone-edit
  fields; ZoneGridSettings and ZoneSpreadSettings now build their oninput
  handlers with it instead of repeating the parse/NaN-guard/updateTransient
  shape.

Keydown policy was already extracted into tabletopKeyboard.ts; the
per-mode reducer switches are left as the standard state-machine idiom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kZeRoDTLJVwWaZHV2KWqX
The spread splay/layout and grid snap are re-establishing steps that
legitimate operations rely on (normalize runs with dev:true after every
commit), so they stay silent. But `placePile` keeps a pile's zoneId and
its container list in lockstep, so a pile listed in a spread/grid whose
zoneId points elsewhere is a structural bug no operation produces.

Thread `fail` into spreadInvariants and gridInvariants and throw on that
membership violation in dev, repairing it (adopting the pile) in prod —
bringing these invariants in line with the SPEC's throw-in-dev rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rLn6HBEptr9pBKpmZjJ2E
tableBoundingBox only walked top-level zoneOrder and rootPileIds, so
fit-view clipped nested child zones and any pile/card whose footprint
overhung its containing zone. state.zones/state.piles are already flat
by id, so walking every entry (instead of only the top-level ones)
covers nesting for free without needing explicit recursion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The v2 pile-based engine is complete; make it the only tabletop path.
Removes the dev toggle and all v1 state model, operations, store,
initialization, and v1-only components (DiceView is kept — it's a
shared, engine-agnostic component still used by v2's CardFace).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
It's the only file left outside _components/v2/ after the v1 removal;
relocating it makes the directory read as v2-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tation-review-mp0t8q

# Conflicts:
#	src/Deckle.Web/src/lib/tabletop/operations.test.ts
#	src/Deckle.Web/src/lib/tabletop/operations.ts
#	src/Deckle.Web/src/lib/tabletop/store.svelte.ts
#	src/Deckle.Web/src/routes/(authenticated)/projects/[username]/[projectCode]/tabletop/_components/ShuffleAnimation.svelte
#	src/Deckle.Web/src/routes/(authenticated)/projects/[username]/[projectCode]/tabletop/_components/Tabletop.svelte
@sonarqubecloud

Copy link
Copy Markdown

@jordanwallwork
jordanwallwork merged commit 8865316 into master Jul 14, 2026
3 checks passed
@jordanwallwork
jordanwallwork deleted the claude/vtt-implementation-review-mp0t8q branch July 14, 2026 20:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants