diff --git a/docs/bgm/interactions.md b/docs/bgm/interactions.md
index e160a21..ffd8211 100644
--- a/docs/bgm/interactions.md
+++ b/docs/bgm/interactions.md
@@ -5,9 +5,10 @@ with a bgm game that has no rules yet: a sandbox. It builds on the state model:
see [`state-model.md`](./state-model.md) for components-vs-setup, path→stack ×
facing, and the anchoring scope.
-> **Status:** Design. Proposes the operation set, the command/dialog split, and
-> the deck-pick-up dialog before the interaction half of `@tts/tabletop` is
-> built.
+> **Status:** Implemented (layer 3, sandbox). The operation set, the
+> command/dialog split, and the deck-pick-up dialog are built in
+> `@tts/tabletop` (`interactions.ts`, `dialog.tsx`). Rule-enforced play (layer
+> 4) is not yet wired — the rule seam is a no-op filter over free interaction.
## 1. The operation set is closed and tiny
diff --git a/docs/status/bgm-tabletop.md b/docs/status/bgm-tabletop.md
index ec25aad..0e0c17f 100644
--- a/docs/status/bgm-tabletop.md
+++ b/docs/status/bgm-tabletop.md
@@ -173,6 +173,28 @@ consumers share them (see Open decisions).
- `pnpm --filter @tts/web build` — the part inspection route renders `PartView`
from the library (work item 2), proving it end-to-end.
+## Free interaction (layer 3) ✅
+
+Sandbox interaction is built per [`../bgm/interactions.md`](../bgm/interactions.md):
+
+- `interactions.ts` — the held part + dialog stack (UI state, outside the game
+ store), plus pure helpers (`interactionsFor`, `dropPaths`, `pickPath`,
+ `partFacings`, `nextFacing`).
+- `state.ts` — `setFacing` alongside `movePart` (the `move(id, path, index?)`
+ primitive, defaulting to top of stack).
+- `placement.tsx` — `PartPlacement` is now interactive: pick up a part (held,
+ lifted above the board), drag to a path anchor to `move`, click to cycle
+ facing. A drop on a path with a stack-dialog interaction opens the deck
+ dialog instead of moving directly.
+- `dialog.tsx` — `DialogLayer`, the stack-inspector dialog: an alternate view
+ of a stack with an insertion cursor; its insert button issues a `move`.
+- `@tts/bgm` — the `dialog` role, `Setup.interactions`, and `Package.dialogs`
+ (schema + collection + serialization).
+- Demo: `games/poker` declares an `interactions:` + `role: dialog` (stack
+ insert) on the deck.
+
+The rule seam (layer 4) is a no-op filter: sandbox applies intents directly.
+
## Commands (not yet implemented)
Scripted interaction is designed in [`../bgm/commands.md`](../bgm/commands.md):
diff --git a/games/poker/poker.md b/games/poker/poker.md
index 8eba2dd..010ded8 100644
--- a/games/poker/poker.md
+++ b/games/poker/poker.md
@@ -142,4 +142,21 @@ setup:
- path: /community/4
parts: poker:card#2h
facing: standing
+interactions:
+ - dialog: prompt#insert
+ on: [/deck]
+```
+
+## Dialog
+
+Inserting a card into the middle of the deck is a compound interaction: the
+deck is lifted into a stack-inspector dialog with an insertion cursor. The
+cursor *is* the index — `move(id, /deck, index)` is discovered by scrolling,
+not typed.
+
+```yaml role=dialog.prompt
+id: insert
+title: Insert into deck
+body: Scroll to the insertion point, then insert the held card.
+widget: stack
```
diff --git a/packages/tabletop/src/dialog.tsx b/packages/tabletop/src/dialog.tsx
new file mode 100644
index 0000000..cf94c8b
--- /dev/null
+++ b/packages/tabletop/src/dialog.tsx
@@ -0,0 +1,133 @@
+/**
+ * The dialog layer — transient UI contexts hosted by the layer-3 shell.
+ *
+ * A dialog is an alternate view of a stack (`docs/bgm/interactions.md` §4):
+ * the deck is "lifted" off the table into the dialog (it stays on its path),
+ * shown in order with an insertion cursor. The cursor *is* the index:
+ * `move(id, path, index)`'s index is discovered by scrolling the visible deck.
+ *
+ * Opening/closing a dialog never issues a command and never touches the game
+ * state — it is pure UI. Only its action buttons issue commands.
+ */
+import { useMemo, useState } from 'react';
+import type { Package } from '@tts/bgm';
+import { useInteractionStore } from './interactions.js';
+import { useTabletopStore, childrenByPath } from './state.js';
+
+/**
+ * Render the topmost dialog on the interaction store's stack as an HTML
+ * overlay. Renders nothing when the stack is empty. The dialog shows the
+ * stack at its path in order, with an insertion cursor the player scrolls;
+ * the insert button issues a `move` of the held part (or the top of the
+ * stack) to that path at the cursor.
+ */
+export function DialogLayer({ pkg }: { pkg: Package }) {
+ const dialogs = useInteractionStore((s) => s.dialogs);
+ const top = dialogs[dialogs.length - 1];
+ if (!top) return null;
+
+ const dialog = pkg.dialogs.get(top.id);
+ if (!dialog) return null;
+
+ return (
+
+ );
+}
+
+/** The stack-inspector dialog: a lifted, ordered view of a path's stack. */
+function StackDialog({
+ pkg,
+ title,
+ body,
+ path,
+}: {
+ pkg: Package;
+ title?: string;
+ body?: string;
+ path: string;
+}) {
+ const parts = useTabletopStore((s) => s.parts);
+ const movePart = useTabletopStore((s) => s.movePart);
+ const held = useInteractionStore((s) => s.held);
+ const drop = useInteractionStore((s) => s.drop);
+ const popDialog = useInteractionStore((s) => s.popDialog);
+
+ // The stack at `path`, in order.
+ const stack = useMemo(() => childrenByPath(parts)[path] ?? [], [parts, path]);
+ const [cursor, setCursor] = useState(stack.length);
+
+ // The part to insert: the held part, or the top of the stack when none is
+ // held (a "look at the top" / reorder use).
+ const source = held?.id;
+
+ const insert = () => {
+ if (!source) return;
+ movePart(source, path, cursor);
+ drop();
+ popDialog();
+ };
+
+ return (
+
+ );
+}
+
+/** A small marker between cards showing the insertion point. */
+function Cursor() {
+ return (
+
+ );
+}
+
+/** A short label for a part id (`package:type#id` → `type#id`). */
+function label(pkg: Package, id: string): string {
+ const key = id.split(':').slice(1).join(':');
+ const part = pkg.parts.get(key);
+ return part?.id ?? key;
+}
diff --git a/packages/tabletop/src/interactions.test.ts b/packages/tabletop/src/interactions.test.ts
new file mode 100644
index 0000000..0c8135a
--- /dev/null
+++ b/packages/tabletop/src/interactions.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from 'vitest';
+import type { Package, Setup } from '@tts/bgm';
+import {
+ interactionsFor,
+ dropPaths,
+ pickPath,
+ partFacings,
+ nextFacing,
+} from './interactions.js';
+
+const pkg: Package = {
+ meta: { id: 'harbor' },
+ parts: new Map([
+ ['card#a', { type: 'card', id: 'a' }],
+ ['card#b', { type: 'card', id: 'b', facing: ['face', 'back'] }],
+ ]),
+ surfaces: new Map([
+ [
+ 'board#harbor',
+ {
+ type: 'board',
+ id: 'harbor',
+ layout: [
+ { route: '/deck', x: 0, y: 0, rotation: 0 },
+ { route: '/discard', x: 40, y: 0, rotation: 0 },
+ ],
+ },
+ ],
+ ]),
+ setups: new Map(),
+ dialogs: new Map(),
+};
+
+const setup: Setup = {
+ type: 'game',
+ id: 'main',
+ setup: [],
+ interactions: [
+ { dialog: 'prompt#insert', on: ['/deck'] },
+ { dialog: 'prompt#shuffle' },
+ ],
+};
+
+describe('interactionsFor', () => {
+ it('returns the interactions whose `on` matches the path, plus any without `on`', () => {
+ expect(interactionsFor(setup, '/deck').map((i) => i.dialog)).toEqual([
+ 'prompt#insert',
+ 'prompt#shuffle',
+ ]);
+ });
+
+ it('includes interactions without `on` for any path', () => {
+ expect(interactionsFor(setup, '/discard').map((i) => i.dialog)).toEqual([
+ 'prompt#shuffle',
+ ]);
+ });
+
+ it('returns [] when the setup declares no interactions', () => {
+ expect(
+ interactionsFor({ type: 'game', id: 'main', setup: [] }, '/deck'),
+ ).toEqual([]);
+ });
+});
+
+describe('dropPaths', () => {
+ it('uses the declared `on` paths when interactions exist', () => {
+ expect([...dropPaths(setup, pkg)]).toEqual(['/deck']);
+ });
+
+ it('falls back to every literal routed path when no interactions are declared', () => {
+ const bare: Setup = { type: 'game', id: 'main', setup: [] };
+ expect([...dropPaths(bare, pkg)].sort()).toEqual(['/deck', '/discard']);
+ });
+});
+
+describe('pickPath', () => {
+ it('returns the nearest literal route within the threshold', () => {
+ expect(pickPath(pkg, 'board#harbor', 41, 1, 40)).toBe('/discard');
+ });
+
+ it('returns null when nothing is within the threshold', () => {
+ expect(pickPath(pkg, 'board#harbor', 100, 100, 40)).toBeNull();
+ });
+
+ it('returns null for an unknown surface', () => {
+ expect(pickPath(pkg, 'board#nope', 0, 0, 40)).toBeNull();
+ });
+});
+
+describe('partFacings / nextFacing', () => {
+ it('defaults to the full set when the part declares none', () => {
+ expect(partFacings(pkg.parts.get('card#a')!)).toEqual([
+ 'face',
+ 'back',
+ 'standing',
+ ]);
+ });
+
+ it('uses the declared affordance', () => {
+ expect(partFacings(pkg.parts.get('card#b')!)).toEqual(['face', 'back']);
+ });
+
+ it('cycles forward through the affordance', () => {
+ const part = pkg.parts.get('card#b')!;
+ expect(nextFacing(part, 'face')).toBe('back');
+ expect(nextFacing(part, 'back')).toBe('face');
+ });
+});
diff --git a/packages/tabletop/src/interactions.ts b/packages/tabletop/src/interactions.ts
new file mode 100644
index 0000000..4118064
--- /dev/null
+++ b/packages/tabletop/src/interactions.ts
@@ -0,0 +1,148 @@
+/**
+ * The free-interaction layer (layer 3 of the layering) — how a player
+ * interacts with a bgm game that has no rules yet: a sandbox.
+ *
+ * The operation set is closed and tiny (`docs/bgm/interactions.md` §1):
+ * `move(id, path, index?)`, `setFacing(id, facing)`, and reorder (a `move`
+ * with an explicit `index`). The game-state store in `state.ts` already
+ * implements `movePart`/`setFacing`; this module adds the *interaction* half:
+ *
+ * - The **held part** — transient "in hand" state that lives outside the store
+ * (a UI-level held part; only the committed drop mutates the store).
+ * - The **dialog stack** — transient UI contexts (the deck pick-up). Opening
+ * or closing a dialog never issues a command and never touches state.
+ *
+ * Both are UI state, hosted by the layer-3 shell, not by the game-state store.
+ */
+import { create } from 'zustand';
+import type { Facing, Interaction, Package, Part, Setup } from '@tts/bgm';
+import type { Placement } from './state.js';
+
+/** The transient "in hand" part, held above the board. */
+export interface HeldPart {
+ /** The part id (`package:type#id`). */
+ id: string;
+ /** The path the part was lifted from, so dropping on nothing returns it. */
+ origin: string;
+}
+
+/** A dialog currently open on the dialog stack. */
+export interface OpenDialog {
+ /** The dialog definition (`type#id`). */
+ id: string;
+ /** The path the dialog was opened on (e.g. the deck being inspected). */
+ path: string;
+}
+
+interface InteractionState {
+ /** The part currently held "in hand", or null. */
+ held: HeldPart | null;
+ /** The dialog stack; the last entry is the topmost dialog. */
+ dialogs: OpenDialog[];
+ /** Lift a part into hand. */
+ hold: (part: HeldPart) => void;
+ /** Drop the held part (returns it to its origin). */
+ drop: () => void;
+ /** Push a dialog onto the stack. */
+ pushDialog: (dialog: OpenDialog) => void;
+ /** Pop the topmost dialog. */
+ popDialog: () => void;
+}
+
+export const useInteractionStore = create((set) => ({
+ held: null,
+ dialogs: [],
+ hold: (part) => set({ held: part }),
+ drop: () => set({ held: null }),
+ pushDialog: (dialog) => set((s) => ({ dialogs: [...s.dialogs, dialog] })),
+ popDialog: () => set((s) => ({ dialogs: s.dialogs.slice(0, -1) })),
+}));
+
+// --- Pure helpers ---
+
+/**
+ * Resolve the interaction declarations for a path from a setup: the `dialog`
+ * refs whose `on` matches the path (or that apply to any path). Returns the
+ * matching `Interaction`s in declaration order.
+ */
+export function interactionsFor(setup: Setup, path: string): Interaction[] {
+ if (!setup.interactions) return [];
+ return setup.interactions.filter(
+ (i) => !i.on || i.on.some((p) => p === path),
+ );
+}
+
+/**
+ * The set of paths a held part may be dropped on, given a setup's declared
+ * interactions. When the setup declares interactions, only the `on` paths of
+ * those interactions are valid drop targets; otherwise any routed path is.
+ */
+export function dropPaths(setup: Setup, pkg: Package): Set {
+ const paths = new Set();
+ if (setup.interactions?.length) {
+ for (const i of setup.interactions) {
+ if (i.on) for (const p of i.on) paths.add(p);
+ }
+ return paths;
+ }
+ // No interactions declared: every routed path is a valid target.
+ for (const surface of pkg.surfaces.values()) {
+ for (const route of surface.layout) {
+ // A literal route is a concrete path; a `:param` route matches many.
+ if (route.route.includes(':')) continue;
+ paths.add(route.route);
+ }
+ }
+ return paths;
+}
+
+/**
+ * Find the nearest routed path anchor to a point in a surface's local plane
+ * (in mm, origin at the surface's anchor), within `threshold` mm. Returns the
+ * matched path, or null when none is within range. Used to resolve a drop.
+ */
+export function pickPath(
+ pkg: Package,
+ surfaceId: string,
+ x: number,
+ y: number,
+ threshold: number,
+): string | null {
+ const surface = pkg.surfaces.get(surfaceId);
+ if (!surface) return null;
+ let best: { path: string; dist: number } | null = null;
+ for (const route of surface.layout) {
+ // A `:param` route's anchor depends on its candidate; without a candidate
+ // we can't place a part there, so skip it.
+ if (route.route.includes(':')) continue;
+ const dx = x - route.x;
+ const dy = y - route.y;
+ const dist = Math.hypot(dx, dy);
+ if (dist <= threshold && (!best || dist < best.dist)) {
+ best = { path: route.route, dist };
+ }
+ }
+ return best?.path ?? null;
+}
+
+/**
+ * The physical facing affordance of a part: the facings it supports. Defaults
+ * to the full set (`face`, `back`, `standing`) when the part doesn't declare
+ * one. A part's `facing` field (a list of supported facings) is an optional
+ * extra on the `Part` definition.
+ */
+export function partFacings(part: Part): Facing[] {
+ const declared = part.facing;
+ const list = Array.isArray(declared) ? (declared as Facing[]) : [];
+ return list.length ? list : ['face', 'back', 'standing'];
+}
+
+/**
+ * The next facing in a part's affordance, cycling forward. Used by a click to
+ * cycle `face → back → standing` (or the declared list).
+ */
+export function nextFacing(part: Part, current: Facing): Facing {
+ const facings = partFacings(part);
+ const i = facings.indexOf(current);
+ return facings[(i + 1) % facings.length] ?? facings[0]!;
+}