Add design spec for pile-based tabletop rework - #88
Merged
Conversation
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
This was referenced Jul 12, 2026
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
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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