Wire a Feature (Three-Layer Wiring)
Every interactive feature in Narrative Cards must exist in all three layers simultaneously. Any layer left missing creates dead code.
When to Use
- •Adding a new hook action to
useGameState.ts - •Adding a button or modal that calls a game rule
- •Connecting an existing hook action that's not yet in the UI
- •Auditing whether a new feature is fully wired
Quick Verification Script
Before or after changes, run:
bash
node .github/skills/wire-feature/scripts/check-wiring.cjs <actionName>
This checks:
- •Layer 1a: action
constdefined inuseGameState.ts - •Layer 1b: action included in the hook's
return {}object - •Layer 2a: action destructured in
Index.tsx - •Layer 2b: action passed as a named prop (e.g.,
onAddFact={addFact}) - •Layer 3a: prop received in a screen
Propsinterface - •Layer 3b: prop called in JSX (a UI trigger exists)
- •Test: a unit test exists for the action
Example output:
code
✅ Layer 1a: 'addFact' defined in useGameState.ts
✅ Layer 1b: 'addFact' included in hook return object
❌ Layer 2a: 'addFact' destructured in Index.tsx
→ Destructure from useGameState(): const { ..., addFact } = useGameState();
Step-by-Step Procedure
Layer 1 — Hook (src/hooks/useGameState.ts)
- •Add the action as a
useCallbackclosure:
typescript
const addFact = useCallback((text: string, creatorId: string) => {
setGameState((prev) => ({
...prev,
facts: [
...prev.facts,
{
id: crypto.randomUUID(),
text,
creatorId,
status: "open",
isSecret: false,
revealed: true,
complications: [],
callbacks: 0,
sceneCreated: prev.sceneCount,
},
],
}));
}, []);
- •Add the action name to the
return {}object at the bottom of the hook.
Golden rules:
- •Always spread with
{ ...prev, ... }— never mutate directly - •Cap meters:
Math.min(10, Math.max(0, prev.tension + delta)) - •Append to arrays:
[...prev.arr, newItem]— neverprev.arr.push()
Layer 2 — Router (src/pages/Index.tsx)
- •Destructure from
useGameState():
typescript
const { ..., addFact } = useGameState();
- •Pass as a prop in all relevant
caseblocks. If the screen isGameplayScreen, add the prop to BOTH'play'AND'scene-end'cases — they render the same component:
typescript
case 'play':
case 'scene-end':
return (
<GameplayScreen
...
onAddFact={addFact}
/>
);
Layer 3 — UI (screen component)
- •Add the prop to the
Propsinterface:
typescript
interface GameplayScreenProps {
...
onAddFact: (text: string, creatorId: string) => void;
}
- •Add a visible trigger in JSX — button, form submit, or modal confirm:
typescript
<Button onClick={() => onAddFact(inputText, currentPlayer.id)}>
Add Fact
</Button>
- •If a modal is involved: create it in
src/components/game/, import it in the screen, render it conditionally with auseStatetoggle.
Unit Test
Add to src/hooks/useGameState.test.ts:
typescript
describe("addFact", () => {
it("adds a new open fact to state", () => {
const result = startPlay();
act(() =>
result.current.addFact(
"The key is missing",
result.current.gameState.players[0].id,
),
);
expect(result.current.gameState.facts).toHaveLength(1);
expect(result.current.gameState.facts[0].status).toBe("open");
});
});
Update the Wiring Map
After wiring, update the Current Action × UI Map table in feature-wiring.instructions.md.
Completion Criteria
Run in order — all must pass:
bash
node .github/skills/wire-feature/scripts/check-wiring.cjs <actionName> npm run lint npm run build npm test
If check-wiring.cjs reports failures, fix each one before running lint/build/test.