diff --git a/docs/superpowers/plans/2026-03-27-computed-for-loop-fix.md b/docs/superpowers/plans/2026-03-27-computed-for-loop-fix.md new file mode 100644 index 0000000..799b6cc --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-computed-for-loop-fix.md @@ -0,0 +1,171 @@ +# Fix: Infinite reactive loop when `{computed}` reads `@` locals inside `{for}` (issue #140) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stabilize object references in `ForIteration` so `{computed}` reading `@` loop variables doesn't trigger an infinite reactive loop. + +**Architecture:** The `ForIteration` component creates a `localState` object (spread of parent + ownKeys + mutations) on every render. This new reference propagates through `LocalsValuesContext` → `useMergedLocals()` → `{computed}`'s `useLayoutEffect` deps, causing infinite re-fires. Fix by memoizing `localState` and decomposing `ownKeys` into individual props so `useMemo` deps are stable across unrelated re-renders. + +**Tech Stack:** Preact, TypeScript, Vitest (happy-dom) + +--- + +### Task 1: Write failing regression test + +**Files:** + +- Modify: `test/dom/macros.test.tsx` (add test inside `describe('{for}', ...)`) + +- [ ] **Step 1: Write the failing test** + +Add this test at the end of the `{for}` describe block (after the last existing `it(...)` in that block): + +```tsx +it('computed reading @local inside for-loop does not infinite-loop (#140)', () => { + useStoryStore.getState().setTemporary('items', [ + { name: 'a', status: 'ok' }, + { name: 'b', status: 'err' }, + ]); + + const container = document.createElement('div'); + const passage = makePassage( + 1, + 'Test', + '{for @item of _items}{computed _derived = @item.status}{@item.name}-{_derived}{/for}', + ); + act(() => { + render(, container); + }); + + const results = container.querySelectorAll('.result'); + expect(results).toHaveLength(2); + expect(results[0].textContent).toBe('a-ok'); + expect(results[1].textContent).toBe('b-err'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails (or hangs)** + +Run: `npx vitest run test/dom/macros.test.tsx -t "computed reading @local" --timeout 5000` + +Expected: Test hangs and times out, or triggers a "Maximum update depth exceeded" error. This confirms the bug exists. + +- [ ] **Step 3: Commit the failing test** + +```bash +git add test/dom/macros.test.tsx +git commit -m "test: add failing regression test for computed + for-loop infinite loop (#140)" +``` + +--- + +### Task 2: Stabilize ForIteration props and memoize localState + +**Files:** + +- Modify: `src/components/macros/For.tsx:54-90` (`ForIteration` component) +- Modify: `src/components/macros/For.tsx:134-148` (render call site) + +- [ ] **Step 1: Change ForIteration props from `ownKeys` object to individual values** + +Replace the `ForIteration` component (lines 54-90) with: + +```tsx +function ForIteration({ + parentValues, + itemVar, + itemValue, + indexVar, + indexValue, + initialValues, + children, +}: { + parentValues: Record; + itemVar: string; + itemValue: unknown; + indexVar: string | null; + indexValue: number; + initialValues: Record; + children: ASTNode[]; +}) { + const [localMutations, setLocalMutations] = useState>( + () => ({ ...initialValues }), + ); + + const ownKeys = useMemo( + () => ({ + [itemVar]: itemValue, + ...(indexVar ? { [indexVar]: indexValue } : undefined), + }), + [itemVar, itemValue, indexVar, indexValue], + ); + + const localState = useMemo( + () => ({ ...parentValues, ...ownKeys, ...localMutations }), + [parentValues, ownKeys, localMutations], + ); + + const valuesRef = useRef(localState); + valuesRef.current = localState; + + const getValues = useCallback(() => valuesRef.current, []); + const update = useCallback((key: string, value: unknown) => { + setLocalMutations((prev) => ({ ...prev, [key]: value })); + }, []); + const updater = useMemo(() => ({ update, getValues }), [update, getValues]); + + const nobr = useContext(NobrContext); + + return ( + + + {renderNodes(children, { nobr, locals: localState })} + + + ); +} +``` + +- [ ] **Step 2: Update the render call site to pass individual props** + +Replace lines 134-148 (the `list.map(...)` block) with: + +```tsx +const content = list.map((item, i) => ( + +)); +``` + +- [ ] **Step 3: Run the regression test** + +Run: `npx vitest run test/dom/macros.test.tsx -t "computed reading @local" --timeout 5000` + +Expected: PASS — renders `a-ok` and `b-err` without hanging. + +- [ ] **Step 4: Run the full test suite** + +Run: `npx vitest run` + +Expected: All tests pass, including the existing `{for}` tests (iteration, index variable, set inside for-loop, button inside for-loop, array identity change). + +- [ ] **Step 5: Run type check** + +Run: `npx tsc --noEmit` + +Expected: No type errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/macros/For.tsx +git commit -m "fix: stabilize ForIteration object refs to prevent infinite reactive loop (#140)" +``` diff --git a/docs/superpowers/plans/2026-03-27-mutation-buffer.md b/docs/superpowers/plans/2026-03-27-mutation-buffer.md new file mode 100644 index 0000000..1c175f8 --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-mutation-buffer.md @@ -0,0 +1,453 @@ +# Mutation Buffer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix issue #136 — consecutive `{set}` macros can't see each other's variable mutations during a Preact render pass. + +**Architecture:** A new `MutationBufferContext` provides a mutable buffer that accumulates `$var` and `_temp` mutations across multiple `executeMutation()` calls within a single render pass. The buffer is provided at the `Passage` and `PassageDialog` component level, read via `useContext` in `defineMacro`, and passed into `executeMutation` as an optional parameter. + +**Tech Stack:** Preact contexts, Zustand store, vitest + +**Spec:** `docs/superpowers/specs/2026-03-27-mutation-buffer-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +| ---------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------- | +| `src/markup/render.tsx` | Modify (lines 28-32) | Add `MutationBuffer` interface and `MutationBufferContext` | +| `src/execute-mutation.ts` | Modify (entire file) | Accept buffer param, read from buffer when populated, write back to buffer + store | +| `src/define-macro.ts` | Modify (lines 14-20, 146-167) | Import context, read via `useContext`, pass to `executeMutation` | +| `src/components/Passage.tsx` | Modify (lines 1, 28-113) | Provide `MutationBufferContext` with `useRef` + `useEffect` cleanup | +| `src/components/PassageDialog.tsx` | Modify (lines 1-2, 18-81) | Provide `MutationBufferContext` | +| `test/unit/expression.test.ts` | Modify (lines 364-391) | Add tests for consecutive mutations with buffer | + +--- + +### Task 1: Add MutationBuffer type and context + +**Files:** + +- Modify: `src/markup/render.tsx:28-32` + +- [ ] **Step 1: Add the MutationBuffer interface and context** + +In `src/markup/render.tsx`, add the interface and context after line 32 (after the existing `WidgetChildrenContext`): + +```typescript +export interface MutationBuffer { + vars: Record; + temps: Record; + populated: boolean; +} + +export const MutationBufferContext = createContext(null); +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `npx tsc --noEmit` +Expected: PASS (no errors) + +- [ ] **Step 3: Commit** + +```bash +git add src/markup/render.tsx +git commit -m "feat: add MutationBuffer type and context" +``` + +--- + +### Task 2: Update executeMutation to use the buffer + +**Files:** + +- Modify: `src/execute-mutation.ts` +- Test: `test/unit/expression.test.ts` + +- [ ] **Step 1: Write failing tests for consecutive mutations with a buffer** + +Add to the existing `describe('executeMutation', ...)` block in `test/unit/expression.test.ts`: + +```typescript +it('consecutive mutations see each other via buffer', () => { + const buffer = { vars: {}, temps: {}, populated: false }; + + // First set: _x = [3, 1, 2] + executeMutation('_x = [3, 1, 2]', {}, () => {}, buffer); + + expect(buffer.populated).toBe(true); + expect(buffer.temps.x).toEqual([3, 1, 2]); + + // Second set: _y = _x.slice().sort() — reads _x from buffer + executeMutation('_y = _x.slice().sort()', {}, () => {}, buffer); + + expect(useStoryStore.getState().temporary.y).toEqual([1, 2, 3]); + expect(buffer.temps.y).toEqual([1, 2, 3]); +}); + +it('consecutive $var mutations see each other via buffer', () => { + const buffer = { vars: {}, temps: {}, populated: false }; + + executeMutation('$x = 10', {}, () => {}, buffer); + executeMutation('$y = $x + 5', {}, () => {}, buffer); + + expect(useStoryStore.getState().variables.y).toBe(15); + expect(buffer.vars.y).toBe(15); +}); + +it('works without buffer (null) for backwards compatibility', () => { + executeMutation('_a = 42', {}, () => {}, null); + expect(useStoryStore.getState().temporary.a).toBe(42); +}); + +it('works without buffer argument for backwards compatibility', () => { + executeMutation('_a = 42', {}, () => {}); + expect(useStoryStore.getState().temporary.a).toBe(42); +}); + +it('buffer tracks deletions', () => { + const buffer = { vars: {}, temps: {}, populated: false }; + + executeMutation('_x = 1; _y = 2', {}, () => {}, buffer); + expect(buffer.temps.x).toBe(1); + expect(buffer.temps.y).toBe(2); + + executeMutation('delete _x', {}, () => {}, buffer); + expect('x' in buffer.temps).toBe(false); + expect(buffer.temps.y).toBe(2); + expect('x' in useStoryStore.getState().temporary).toBe(false); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/expression.test.ts` +Expected: FAIL — `executeMutation` doesn't accept a 4th argument yet + +- [ ] **Step 3: Implement the buffer-aware executeMutation** + +Replace the entire contents of `src/execute-mutation.ts`: + +```typescript +import { useStoryStore } from './store'; +import { execute } from './expression'; +import { deepClone } from './class-registry'; +import type { MutationBuffer } from './markup/render'; + +export function executeMutation( + code: string, + mergedLocals: Record, + scopeUpdate: (key: string, value: unknown) => void, + buffer: MutationBuffer | null = null, +): void { + const state = useStoryStore.getState(); + + // Read from buffer if populated, otherwise from store + const baseVars = buffer?.populated ? buffer.vars : state.variables; + const baseTemps = buffer?.populated ? buffer.temps : state.temporary; + const vars = deepClone(baseVars); + const temps = deepClone(baseTemps); + const localsClone = { ...mergedLocals }; + + execute(code, vars, temps, localsClone); + + // Write changed values to store + for (const key of Object.keys(vars)) { + if (vars[key] !== state.variables[key]) { + state.setVariable(key, vars[key]); + } + } + for (const key of Object.keys(temps)) { + if (temps[key] !== state.temporary[key]) { + state.setTemporary(key, temps[key]); + } + } + for (const key of Object.keys(localsClone)) { + if (localsClone[key] !== mergedLocals[key]) { + scopeUpdate(key, localsClone[key]); + } + } + + // Detect deleted keys (compare against the base we read from) + for (const key of Object.keys(baseVars)) { + if (!(key in vars)) { + state.deleteVariable(key); + } + } + for (const key of Object.keys(baseTemps)) { + if (!(key in temps)) { + state.deleteTemporary(key); + } + } + for (const key of Object.keys(mergedLocals)) { + if (!(key in localsClone)) { + scopeUpdate(key, undefined); + } + } + + // Update buffer with full post-mutation snapshot + if (buffer) { + buffer.vars = vars; + buffer.temps = temps; + buffer.populated = true; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/unit/expression.test.ts` +Expected: ALL PASS + +- [ ] **Step 5: Run full typecheck** + +Run: `npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/execute-mutation.ts test/unit/expression.test.ts +git commit -m "feat: executeMutation reads/writes MutationBuffer for cross-set visibility" +``` + +--- + +### Task 3: Wire the buffer through defineMacro + +**Files:** + +- Modify: `src/define-macro.ts:14-20,146-167` + +- [ ] **Step 1: Import MutationBufferContext and pass buffer to executeMutation** + +In `src/define-macro.ts`, add `MutationBufferContext` to the existing import from `./markup/render` (line 14-20): + +```typescript +import { + LocalsUpdateContext, + LocalsValuesContext, + MutationBufferContext, + NobrContext, + renderNodes as _renderNodes, + renderInlineNodes, +} from './markup/render'; +``` + +In the `Wrapper` function body, after the existing `useContext` calls (after line 148), add: + +```typescript +const mutationBuffer = useContext(MutationBufferContext); +``` + +Change the `mutate` line (line 167) from: + +```typescript + mutate: (code: string) => executeMutation(code, getValues(), update), +``` + +to: + +```typescript + mutate: (code: string) => executeMutation(code, getValues(), update, mutationBuffer), +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 3: Run all tests** + +Run: `npx vitest run` +Expected: ALL PASS (buffer is `null` when no provider present — backwards compatible) + +- [ ] **Step 4: Commit** + +```bash +git add src/define-macro.ts +git commit -m "feat: pass MutationBufferContext to executeMutation in defineMacro" +``` + +--- + +### Task 4: Provide the buffer in Passage + +**Files:** + +- Modify: `src/components/Passage.tsx` + +- [ ] **Step 1: Add MutationBufferContext provider** + +Update the imports (line 1) to add `useRef`: + +```typescript +import { useMemo, useEffect, useRef, useState } from 'preact/hooks'; +``` + +Update the import from `../markup/render` (line 4) to add `MutationBufferContext`: + +```typescript +import { + renderNodes, + NobrContext, + MutationBufferContext, +} from '../markup/render'; +import type { MutationBuffer } from '../markup/render'; +``` + +Inside the `Passage` component, after the `const nobr` line (line 93), add the buffer ref and cleanup effect: + +```typescript +const bufferRef = useRef({ + vars: {}, + temps: {}, + populated: false, +}); +useEffect(() => { + const buf = bufferRef.current; + buf.vars = {}; + buf.temps = {}; + buf.populated = false; +}); +``` + +Wrap the return value to provide the context. Replace lines 109-113: + +```typescript + return ( + + {nobr ? ( + {inner} + ) : ( + inner + )} + + ); +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 3: Run all tests** + +Run: `npx vitest run` +Expected: ALL PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/components/Passage.tsx +git commit -m "feat: provide MutationBufferContext in Passage component" +``` + +--- + +### Task 5: Provide the buffer in PassageDialog + +**Files:** + +- Modify: `src/components/PassageDialog.tsx` + +- [ ] **Step 1: Add MutationBufferContext provider** + +Update the imports (lines 1-2): + +```typescript +import { createContext } from 'preact'; +import { useCallback, useEffect, useMemo, useRef } from 'preact/hooks'; +``` + +Add import for the context and type: + +```typescript +import { renderNodes, MutationBufferContext } from '../markup/render'; +import type { MutationBuffer } from '../markup/render'; +``` + +Remove the existing `renderNodes` import from line 5 (`import { renderNodes } from '../markup/render';`). + +Inside the `PassageDialog` component, after the `const markup` line (line 35), add: + +```typescript +const bufferRef = useRef({ + vars: {}, + temps: {}, + populated: false, +}); +useEffect(() => { + const buf = bufferRef.current; + buf.vars = {}; + buf.temps = {}; + buf.populated = false; +}); +``` + +Wrap the return JSX. Replace lines 62-81: + +```typescript + return ( + + + + + {showCloseButton && ( + + ✕ + + )} + {content} + + + + + ); +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 3: Run all tests** + +Run: `npx vitest run` +Expected: ALL PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/components/PassageDialog.tsx +git commit -m "feat: provide MutationBufferContext in PassageDialog component" +``` + +--- + +### Task 6: Final verification + +- [ ] **Step 1: Run full test suite** + +Run: `npx vitest run` +Expected: ALL PASS + +- [ ] **Step 2: Run typecheck** + +Run: `npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 3: Verify the original reproduction case would work** + +The fix ensures that when two `{set}` components render in sequence within a `Passage`, the second `executeMutation` call reads from the buffer (populated by the first call) rather than from the Zustand store. Manually verify by tracing the data flow: + +1. `{set _x = [3, 1, 2]}` → `executeMutation("_x = [3, 1, 2]", ..., buffer)` → buffer not populated → reads store `{}` → executes → buffer = `{x: [3,1,2]}`, `populated = true` +2. `{set _y = _x.slice().sort()}` → `executeMutation("_y = _x.slice().sort()", ..., buffer)` → buffer populated → reads buffer `{x: [3,1,2]}` → executes → `_y = [1,2,3]` → store + buffer updated diff --git a/docs/superpowers/plans/2026-03-27-transient-variables.md b/docs/superpowers/plans/2026-03-27-transient-variables.md new file mode 100644 index 0000000..1a3003d --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-transient-variables.md @@ -0,0 +1,1631 @@ +# Transient Variables Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a new `%` variable scope (transient) that is reactive but excluded from all persistence (history, saves, session). + +**Architecture:** New `transient` + `transientDefaults` dicts in the Zustand store, `%` sigil recognition in expression engine/tokenizer/interpolation, `StoryTransients` special passage for declarations, sigil-based routing in Story API `get()`/`set()`, and exclusion from all serialization paths. + +**Tech Stack:** Preact, TypeScript, Zustand (with Immer), Vitest + +--- + +### Task 1: Expression Engine — `%` Sigil Transform + +**Files:** + +- Modify: `src/expression.ts:21-26,36-44,202-227,296-321` +- Test: `test/unit/expression.test.ts` + +- [ ] **Step 1: Write failing tests for `%` variable transform** + +Add to `test/unit/expression.test.ts` in the existing `describe('evaluate')` block: + +```typescript +it('reads %transient variables', () => { + expect(evaluate('%count', {}, {}, {}, { count: 7 })).toBe(7); +}); + +it('handles mixed $, _, @, and % variables', () => { + expect( + evaluate('@x + $y + _z + %w', { y: 10 }, { z: 20 }, { x: 5 }, { w: 3 }), + ).toBe(38); +}); + +it('returns undefined for missing %transient', () => { + expect(evaluate('%missing', {}, {}, {}, {})).toBeUndefined(); +}); + +it('resolves %transient dot paths', () => { + expect(evaluate('%obj.name', {}, {}, {}, { obj: { name: 'test' } })).toBe( + 'test', + ); +}); + +it('does not transform % inside string literals', () => { + expect(evaluate('"100%"', {}, {}, {}, {})).toBe('100%'); +}); + +it('preserves modulo operator with word chars before %', () => { + expect(evaluate('10 % 3', {}, {}, {}, {})).toBe(1); + expect(evaluate('10%3', {}, {}, {}, {})).toBe(1); +}); + +it('distinguishes modulo from %transient', () => { + expect(evaluate('%x + 10 % 3', {}, {}, {}, { x: 5 })).toBe(6); +}); +``` + +Add to the existing `describe('execute')` block: + +```typescript +it('sets a %transient variable', () => { + const trans: Record = {}; + execute('%count = 42', {}, {}, {}, trans); + expect(trans.count).toBe(42); +}); + +it('modifies existing %transient', () => { + const trans: Record = { x: 10 }; + execute('%x = %x + 5', {}, {}, {}, trans); + expect(trans.x).toBe(15); +}); + +it('can mix % and $ in assignment', () => { + const vars: Record = { total: 0 }; + const trans: Record = { bonus: 10 }; + execute('$total = $total + %bonus', vars, {}, {}, trans); + expect(vars.total).toBe(10); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/expression.test.ts` +Expected: FAIL — `evaluate` and `execute` don't accept a 5th argument + +- [ ] **Step 3: Add `%` transform and `transient` parameter to expression engine** + +In `src/expression.ts`: + +Add the new regex at line 38 (after `LOCAL_RE`): + +```typescript +const TRANS_RE = /(?, + temporary: Record, + locals: Record, + __fns: ExpressionFns, + transient: Record, +) => unknown; +``` + +Update `getOrCompile` (line 213-219) — add `'transient'` parameter to `new Function`: + +```typescript +const fn = new Function( + 'variables', + 'temporary', + 'locals', + '__fns', + 'transient', + preamble + body, +) as CompiledExpression; +``` + +Update `evaluate` (line 296-306) — add `transient` parameter: + +```typescript +export function evaluate( + expr: string, + variables: Record, + temporary: Record, + locals: Record = {}, + transient: Record = {}, +): unknown { + const transformed = transform(expr); + const body = `return (${transformed});`; + const fn = getOrCompile(body, body); + return fn(variables, temporary, locals, buildExpressionFns(), transient); +} +``` + +Update `execute` (line 312-321) — add `transient` parameter: + +```typescript +export function execute( + code: string, + variables: Record, + temporary: Record, + locals: Record = {}, + transient: Record = {}, +): void { + const transformed = transform(code); + const fn = getOrCompile('exec:' + transformed, transformed); + fn(variables, temporary, locals, buildExpressionFns(), transient); +} +``` + +Update `evaluateWithState` (line 334-336): + +```typescript +export function evaluateWithState(expr: string, state: StoryState): unknown { + return evaluate(expr, state.variables, state.temporary, {}, state.transient); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/unit/expression.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/expression.ts test/unit/expression.test.ts +git commit -m "feat: add % transient sigil to expression engine (#137)" +``` + +--- + +### Task 2: Store — `transient` + `transientDefaults` State + +**Files:** + +- Modify: `src/store.ts:41-51,235-293,315-376,441-493,526-588,724-818` +- Test: `test/unit/store.test.ts` (create if needed, or add to existing) + +- [ ] **Step 1: Write failing tests for transient store operations** + +Create or add to a store test file. These tests verify the store's transient behavior: + +```typescript +import { useStoryStore } from '../../src/store'; + +describe('transient store', () => { + it('setTransient writes to transient dict', () => { + const state = useStoryStore.getState(); + state.setTransient('foo', 42); + expect(useStoryStore.getState().transient.foo).toBe(42); + }); + + it('deleteTransient removes from transient dict', () => { + const state = useStoryStore.getState(); + state.setTransient('foo', 42); + state.deleteTransient('foo'); + expect('foo' in useStoryStore.getState().transient).toBe(false); + }); + + it('transient survives navigate', () => { + const state = useStoryStore.getState(); + state.setTransient('foo', 42); + state.navigate('SomePassage'); + expect(useStoryStore.getState().transient.foo).toBe(42); + }); + + it('transient stays current on goBack', () => { + const state = useStoryStore.getState(); + state.navigate('Page2'); + state.setTransient('foo', 'live'); + state.goBack(); + expect(useStoryStore.getState().transient.foo).toBe('live'); + }); + + it('transient resets to defaults on restart', () => { + const state = useStoryStore.getState(); + state.setTransient('foo', 'modified'); + state.restart(); + expect(useStoryStore.getState().transient.foo).toBeUndefined(); + }); + + it('transient excluded from getSavePayload', () => { + const state = useStoryStore.getState(); + state.setTransient('big', { data: 'lots' }); + const payload = state.getSavePayload(); + expect(payload.variables).not.toHaveProperty('big'); + // transient is not in the payload at all + expect((payload as any).transient).toBeUndefined(); + }); + + it('transient resets to defaults on loadFromPayload', () => { + const state = useStoryStore.getState(); + state.setTransient('foo', 'modified'); + const payload = state.getSavePayload(); + state.setTransient('foo', 'changed again'); + state.loadFromPayload(payload); + // After load, transient resets to transientDefaults (not preserved from save) + expect(useStoryStore.getState().transient).toEqual( + useStoryStore.getState().transientDefaults, + ); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/store.test.ts` +Expected: FAIL — `setTransient`, `deleteTransient`, `transient` don't exist on the store + +- [ ] **Step 3: Add transient state to store** + +In `src/store.ts`: + +Add `'StoryTransients'` to `SPECIAL_PASSAGES` set (line 41-51): + +```typescript +const SPECIAL_PASSAGES = new Set([ + 'StoryInit', + 'StoryInterface', + 'StoryVariables', + 'StoryTransients', + 'StoryLoading', + 'SaveTitle', + 'PassageReady', + 'PassageHeader', + 'PassageFooter', + 'PassageDone', +]); +``` + +Add to `StoryState` interface (after `variableDefaults` at ~line 239): + +```typescript +transient: Record; +transientDefaults: Record; +``` + +Add to `StoryState` interface actions (after `deleteTemporary`): + +```typescript + setTransient: (name: string, value: unknown) => void; + deleteTransient: (name: string) => void; +``` + +Add default values in the store creator (after the existing `variableDefaults: {}` at ~line 293): + +```typescript + transient: {}, + transientDefaults: {}, +``` + +In `init()` (~line 317) — accept `transientDefaults` parameter and initialize: + +```typescript +init: ( + storyData: StoryData, + variableDefaults: Record = {}, + transientDefaults: Record = {}, +) => { +``` + +Inside the `set()` call in `init()`, add: + +```typescript +state.transient = deepClone(transientDefaults); +state.transientDefaults = transientDefaults; +``` + +In `restart()` (~line 526) — reset transient: + +After `const { storyData, variableDefaults } = get();` add `transientDefaults`: + +```typescript +const { storyData, variableDefaults, transientDefaults } = get(); +``` + +Inside the `set()` call in `restart()`, add: + +```typescript +state.transient = deepClone(transientDefaults); +``` + +In `loadFromPayload()` — reset transient to defaults: + +Inside the `set()` call, add: + +```typescript +state.transient = deepClone(get().transientDefaults); +``` + +Add `setTransient` and `deleteTransient` actions (after `deleteTemporary`): + +```typescript + setTransient: (name: string, value: unknown) => { + set((state) => { + state.transient[name] = value; + }); + }, + + deleteTransient: (name: string) => { + set((state) => { + delete state.transient[name]; + }); + }, +``` + +**Important:** `navigate()`, `goBack()`, `goForward()` do NOT touch `transient` — no changes needed to those methods. `getSavePayload()` does NOT include `transient` — no changes needed. `persistSession()` does NOT include `transient` — no changes needed. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/unit/store.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/store.ts test/unit/store.test.ts +git commit -m "feat: add transient + transientDefaults to store (#137)" +``` + +--- + +### Task 3: StoryTransients Passage Parsing + +**Files:** + +- Modify: `src/story-variables.ts:15,45-76` +- Modify: `src/index.tsx:76-101` +- Test: `test/unit/story-variables.test.ts` (create or extend) + +- [ ] **Step 1: Write failing tests for StoryTransients parsing** + +```typescript +import { parseStoryVariables } from '../../src/story-variables'; + +describe('parseStoryVariables with % sigil', () => { + it('parses %transient declarations', () => { + const schema = parseStoryVariables('%npcList = []\n%agents = {}', '%'); + expect(schema.has('npcList')).toBe(true); + expect(schema.get('npcList')!.default).toEqual([]); + expect(schema.has('agents')).toBe(true); + expect(schema.get('agents')!.default).toEqual({}); + }); + + it('rejects $ declarations in StoryTransients (wrong sigil)', () => { + expect(() => parseStoryVariables('$health = 100', '%')).toThrow( + /Expected: %name = value/, + ); + }); + + it('rejects % declarations in StoryVariables (wrong sigil)', () => { + expect(() => parseStoryVariables('%npcList = []', '$')).toThrow( + /Expected: \$name = value/, + ); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/story-variables.test.ts` +Expected: FAIL — `parseStoryVariables` doesn't accept a sigil parameter + +- [ ] **Step 3: Parameterize parseStoryVariables with sigil** + +In `src/story-variables.ts`: + +Change `DECLARATION_RE` (line 15) to a function that accepts a sigil: + +```typescript +function declarationRegex(sigil: string): RegExp { + const escaped = sigil.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^${escaped}(\\w+)\\s*=\\s*(.+)$`); +} +``` + +Update `parseStoryVariables` signature (line 45) to accept an optional sigil parameter: + +```typescript +export function parseStoryVariables( + content: string, + sigil: '$' | '%' = '$', +): Map { + const schema = new Map(); + const DECLARATION_RE = declarationRegex(sigil); + const passageName = sigil === '%' ? 'StoryTransients' : 'StoryVariables'; + + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + + const match = line.match(DECLARATION_RE); + if (!match) { + throw new Error( + `${passageName}: Invalid declaration: "${line}". Expected: ${sigil}name = value`, + ); + } + + const [, name, expr] = match as [string, string, string]; + let value: unknown; + try { + value = new Function('return (' + expr + ')')(); + } catch (err) { + throw new Error( + `${passageName}: Failed to evaluate "${sigil}${name} = ${expr}": ${err instanceof Error ? err.message : err}`, + ); + } + + const fieldSchema = inferSchema(value); + schema.set(name, { ...fieldSchema, name, default: value }); + } + + return schema; +} +``` + +- [ ] **Step 4: Update index.tsx to parse StoryTransients and pass transientDefaults to init** + +In `src/index.tsx`, after the `StoryVariables` parsing block (~line 99), add: + +```typescript +// Parse StoryTransients (optional — no error if missing) +let transientDefaults: Record = {}; +const storyTransientsPassage = storyData.passages.get('StoryTransients'); +if (storyTransientsPassage) { + const transientSchema = parseStoryVariables( + storyTransientsPassage.content, + '%', + ); + + // Check for cross-scope name collisions + for (const name of transientSchema.keys()) { + if (schema.has(name)) { + errors.push( + `StoryTransients: Variable "${name}" is already declared in StoryVariables. Names must be unique across scopes.`, + ); + } + } + + transientDefaults = extractDefaults(transientSchema); +} +``` + +Update the `init()` call to pass `transientDefaults`: + +```typescript +useStoryStore.getState().init(storyData, defaults, transientDefaults); +``` + +Also add `'StoryTransients'` to the skip list in `validatePassages` (in `src/story-variables.ts` at line 150): + +```typescript +if (name === 'StoryVariables' || name === 'StoryTransients') continue; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run test/unit/story-variables.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/story-variables.ts src/index.tsx test/unit/story-variables.test.ts +git commit -m "feat: add StoryTransients passage parsing (#137)" +``` + +--- + +### Task 4: Tokenizer — `%` Variable Display Tokens + +**Files:** + +- Modify: `src/markup/tokenizer.ts:29-37,547-664` +- Modify: `src/markup/ast.ts:8-14` +- Test: `test/unit/tokenizer.test.ts` + +- [ ] **Step 1: Write failing tests for `{%var}` tokenization** + +Add to the tokenizer test file: + +```typescript +it('tokenizes {%transient} as variable with scope transient', () => { + const tokens = tokenize('{%npcList}'); + expect(tokens).toEqual([ + expect.objectContaining({ + type: 'variable', + name: 'npcList', + scope: 'transient', + }), + ]); +}); + +it('tokenizes {%obj.field} with dot path', () => { + const tokens = tokenize('{%obj.field.sub}'); + expect(tokens).toEqual([ + expect.objectContaining({ + type: 'variable', + name: 'obj.field.sub', + scope: 'transient', + }), + ]); +}); + +it('tokenizes {.class %var} with CSS selector', () => { + const tokens = tokenize('{.red %health}'); + expect(tokens).toEqual([ + expect.objectContaining({ + type: 'variable', + name: 'health', + scope: 'transient', + className: 'red', + }), + ]); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/tokenizer.test.ts` +Expected: FAIL — `%` not recognized as a variable sigil + +- [ ] **Step 3: Add `%` to VariableToken scope and tokenizer** + +In `src/markup/ast.ts` (line 11), update the scope union: + +```typescript +scope: 'variable' | 'temporary' | 'local' | 'transient'; +``` + +In `src/markup/tokenizer.ts` (line 32), update the scope union: + +```typescript +scope: 'variable' | 'temporary' | 'local' | 'transient'; +``` + +In the tokenizer, after the `{@local}` block (line 664), add a new block for `{%transient}`. Copy the same pattern as `{$variable}` (lines 547-584) but with `nextChar === '%'` and `scope: 'transient'`: + +```typescript +// {%transient.field} or {%expr[...]} +if (nextChar === '%') { + flushText(i); + i += 2; + const nameStart = i; + while (i < input.length && /[\w.]/.test(input[i]!)) i++; + const name = input.slice(nameStart, i); + + if (input[i] === '}') { + i++; // skip } + tokens.push({ + type: 'variable', + name, + scope: 'transient', + start, + end: i, + }); + textStart = i; + continue; + } + // Complex expression — scan for balanced closing } + const closeIdx = scanBalancedBrace(input, nameStart); + if (closeIdx !== -1) { + const expression = input.slice(start + 1, closeIdx); + i = closeIdx + 1; + tokens.push({ + type: 'expression', + expression, + start, + end: i, + }); + textStart = i; + continue; + } + // Unbalanced — treat as text + i = start + 1; + textStart = start; + continue; +} +``` + +Also handle `%` in the CSS-selector-prefixed variable path. After the `charAfter === '@'` block (~line 457), add: + +```typescript +if (charAfter === '%') { + // {.class#id %transient.field} or {.class %expr[...]} + i = afterSelectors + 1; + const nameStart = i; + while (i < input.length && /[\w.]/.test(input[i]!)) i++; + const name = input.slice(nameStart, i); + + if (input[i] === '}') { + i++; // skip } + const token: VariableToken = { + type: 'variable', + name, + scope: 'transient', + start, + end: i, + }; + if (className) token.className = className; + if (id) token.id = id; + tokens.push(token); + textStart = i; + continue; + } + // Complex expression — scan for balanced closing } + const closeIdx = scanBalancedBrace(input, nameStart); + if (closeIdx !== -1) { + const expression = input.slice(afterSelectors, closeIdx); + i = closeIdx + 1; + const token: ExpressionToken = { + type: 'expression', + expression, + start, + end: i, + }; + if (className) token.className = className; + if (id) token.id = id; + tokens.push(token); + textStart = i; + continue; + } + // Unbalanced — treat as text + i = start + 1; + textStart = start; + continue; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/unit/tokenizer.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/markup/tokenizer.ts src/markup/ast.ts test/unit/tokenizer.test.ts +git commit -m "feat: add % sigil to tokenizer and AST (#137)" +``` + +--- + +### Task 5: VarDisplay + Render — Display `{%var}` Values + +**Files:** + +- Modify: `src/components/macros/VarDisplay.tsx:6-26` +- Modify: `src/markup/render.tsx:235-254` + +- [ ] **Step 1: Update VarDisplay to handle `transient` scope** + +In `src/components/macros/VarDisplay.tsx`: + +Update the `VarDisplayProps` interface (line 8): + +```typescript +scope: 'variable' | 'temporary' | 'local' | 'transient'; +``` + +Update the `useStoryStore` selector (lines 20-26) to include transient: + +```typescript +const storeValue = useStoryStore((s) => + scope === 'variable' + ? s.variables[root] + : scope === 'temporary' + ? s.temporary[root] + : scope === 'transient' + ? s.transient[root] + : undefined, +); +``` + +- [ ] **Step 2: Update getVariableTextValue in render.tsx** + +In `src/markup/render.tsx`, update `getVariableTextValue` (lines 243-246): + +```typescript +if (node.scope === 'variable') value = state.variables[root]; +else if (node.scope === 'temporary') value = state.temporary[root]; +else if (node.scope === 'transient') value = state.transient[root]; +else value = locals[root]; +``` + +- [ ] **Step 3: Run type check** + +Run: `npx tsc --noEmit` +Expected: PASS (no type errors) + +- [ ] **Step 4: Commit** + +```bash +git add src/components/macros/VarDisplay.tsx src/markup/render.tsx +git commit -m "feat: display {%var} transient values (#137)" +``` + +--- + +### Task 6: Interpolation — `%` Sigil Support + +**Files:** + +- Modify: `src/interpolation.ts:4,33-58,88-93,114` + +- [ ] **Step 1: Write failing test for `%` interpolation** + +Add to interpolation tests: + +```typescript +it('interpolates {%transient} variables', () => { + expect(interpolate('Value: {%foo}', {}, {}, {}, { foo: 42 })).toBe( + 'Value: 42', + ); +}); + +it('interpolates {%obj.field} dot paths', () => { + expect( + interpolate('{%obj.name}', {}, {}, {}, { obj: { name: 'test' } }), + ).toBe('test'); +}); + +it('hasInterpolation detects %', () => { + expect(hasInterpolation('{%foo}')).toBe(true); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/interpolation.test.ts` +Expected: FAIL + +- [ ] **Step 3: Add `%` to interpolation** + +In `src/interpolation.ts`: + +Update `INTERP_TEST` (line 4) to include `%`: + +```typescript +const INTERP_TEST = /\{[\$_@%]\w/; +``` + +Add `transient` parameter to `interpolateExpression` (line 23-31): + +```typescript +export function interpolateExpression( + expr: string, + variables: Record, + temporary: Record, + locals: Record, + transient: Record = {}, +): string { + const value = evaluate(expr, variables, temporary, locals, transient); + return value == null ? '' : String(value); +} +``` + +Add `transient` parameter to `resolveSimple` (line 33-58): + +```typescript +function resolveSimple( + ref: string, + variables: Record, + temporary: Record, + locals: Record, + transient: Record, +): string { + const prefix = ref[0]!; + const path = ref.slice(1); + const parts = path.split('.'); + const root = parts[0]!; + + let value: unknown; + if (prefix === '$') { + value = variables[root]; + } else if (prefix === '_') { + value = temporary[root]; + } else if (prefix === '%') { + value = transient[root]; + } else { + value = locals[root]; + } + + if (parts.length > 1) { + value = resolveDotPath(value, parts); + } + + return value == null ? '' : String(value); +} +``` + +Add `transient` parameter to `interpolate` (line 60-122): + +```typescript +export function interpolate( + template: string, + variables: Record, + temporary: Record, + locals: Record, + transient: Record = {}, +): string { +``` + +Update the sigil check (line 89): + +```typescript + if (sigil !== '$' && sigil !== '_' && sigil !== '@' && sigil !== '%') { +``` + +Update the simple dot-path regex (line 114): + +```typescript +if (/^[\$_@%][\w.]+$/.test(inner)) { + result += resolveSimple(inner, variables, temporary, locals, transient); +} else { + result += interpolateExpression( + inner, + variables, + temporary, + locals, + transient, + ); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/unit/interpolation.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/interpolation.ts test/unit/interpolation.test.ts +git commit -m "feat: add % sigil to interpolation engine (#137)" +``` + +--- + +### Task 7: useMergedLocals → 4-Tuple + Downstream Callers + +**Files:** + +- Modify: `src/hooks/use-merged-locals.ts` +- Modify: `src/hooks/use-interpolate.ts` +- Modify: `src/define-macro.ts:177-182` +- Modify: `src/execute-mutation.ts` + +- [ ] **Step 1: Extend useMergedLocals to return 4-tuple** + +In `src/hooks/use-merged-locals.ts`: + +```typescript +export function useMergedLocals(): readonly [ + Record, + Record, + Record, + Record, +] { + const variables = useStoryStore((s) => s.variables); + const temporary = useStoryStore((s) => s.temporary); + const transient = useStoryStore((s) => s.transient); + const localsValues = useContext(LocalsValuesContext); + + return useMemo(() => { + return [variables, temporary, localsValues, transient] as const; + }, [variables, temporary, localsValues, transient]); +} +``` + +- [ ] **Step 2: Update useInterpolate to pass transient** + +In `src/hooks/use-interpolate.ts`: + +```typescript +export function useInterpolate(): ( + s: string | undefined, +) => string | undefined { + const [variables, temporary, locals, transient] = useMergedLocals(); + + return useCallback( + (s: string | undefined): string | undefined => { + if (s === undefined || !hasInterpolation(s)) return s; + return interpolate(s, variables, temporary, locals, transient); + }, + [variables, temporary, locals, transient], + ); +} +``` + +- [ ] **Step 3: Update define-macro.ts merged flag to pass transient** + +In `src/define-macro.ts` (lines 177-182): + +```typescript +if (config.merged) { + ctx.merged = useMergedLocals(); + const merged = ctx.merged; + ctx.evaluate = (expr: string) => + evaluate(expr, merged[0], merged[1], merged[2], merged[3]); +} +``` + +- [ ] **Step 4: Update executeMutation to include transient** + +In `src/execute-mutation.ts`: + +```typescript +export function executeMutation( + code: string, + mergedLocals: Record, + scopeUpdate: (key: string, value: unknown) => void, +): void { + const state = useStoryStore.getState(); + const vars = deepClone(state.variables); + const temps = deepClone(state.temporary); + const trans = deepClone(state.transient); + const localsClone = { ...mergedLocals }; + + execute(code, vars, temps, localsClone, trans); + + for (const key of Object.keys(vars)) { + if (vars[key] !== state.variables[key]) { + state.setVariable(key, vars[key]); + } + } + for (const key of Object.keys(temps)) { + if (temps[key] !== state.temporary[key]) { + state.setTemporary(key, temps[key]); + } + } + for (const key of Object.keys(trans)) { + if (trans[key] !== state.transient[key]) { + state.setTransient(key, trans[key]); + } + } + for (const key of Object.keys(localsClone)) { + if (localsClone[key] !== mergedLocals[key]) { + scopeUpdate(key, localsClone[key]); + } + } + + // Detect deleted keys + for (const key of Object.keys(state.variables)) { + if (!(key in vars)) { + state.deleteVariable(key); + } + } + for (const key of Object.keys(state.temporary)) { + if (!(key in temps)) { + state.deleteTemporary(key); + } + } + for (const key of Object.keys(state.transient)) { + if (!(key in trans)) { + state.deleteTransient(key); + } + } + for (const key of Object.keys(mergedLocals)) { + if (!(key in localsClone)) { + scopeUpdate(key, undefined); + } + } +} +``` + +- [ ] **Step 5: Run type check and existing tests** + +Run: `npx tsc --noEmit && npx vitest run` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/hooks/use-merged-locals.ts src/hooks/use-interpolate.ts src/define-macro.ts src/execute-mutation.ts +git commit -m "feat: propagate transient through hooks, macros, and mutations (#137)" +``` + +--- + +### Task 8: Unset Macro — `%` Sigil Routing + +**Files:** + +- Modify: `src/components/macros/Unset.tsx:14-24` + +- [ ] **Step 1: Add `%` branch to Unset macro** + +In `src/components/macros/Unset.tsx`, update the sigil routing (lines 14-24): + +```typescript +if (name.startsWith('$')) { + state.deleteVariable(name.slice(1)); +} else if (name.startsWith('_')) { + state.deleteTemporary(name.slice(1)); +} else if (name.startsWith('%')) { + state.deleteTransient(name.slice(1)); +} else if (name.startsWith('@')) { + ctx.update(name.slice(1), undefined); +} else { + console.error( + `spindle: {unset} expects a variable ($name, _name, %name, or @name), got "${name}"`, + ); +} +``` + +- [ ] **Step 2: Run type check** + +Run: `npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/components/macros/Unset.tsx +git commit -m "feat: add % sigil routing to {unset} macro (#137)" +``` + +--- + +### Task 9: storeVar Rejection — Input Macros Reject `%` + +**Files:** + +- Modify: `src/define-macro.ts:184-202` + +- [ ] **Step 1: Write failing test for storeVar rejection** + +Add to `test/dom/render.test.tsx` (which uses happy-dom and the existing `renderMarkup` helper): + +```typescript +it('textbox rejects %transient variable binding', () => { + useStoryStore.getState().setTransient('foo', 'bar'); + const el = renderMarkup('{textbox "%foo"}'); + expect(el.querySelector('.error')).not.toBeNull(); + expect(el.textContent).toContain('transient variables'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/dom/render.test.tsx -t "textbox rejects"` +Expected: FAIL — storeVar currently accepts `%` without error + +- [ ] **Step 3: Add `%` sigil guard to storeVar handling** + +In `src/define-macro.ts`, at the start of the `if (config.storeVar)` block (line 184), add a check: + +```typescript + if (config.storeVar) { + const firstToken = + props.rawArgs.trim().split(/\s+/)[0]?.replace(/["']/g, '') ?? ''; + + if (firstToken.startsWith('%')) { + return h('span', { class: 'error' }, + `{${config.name}}: transient variables (%${firstToken.slice(1)}) cannot be bound to input macros`, + ); + } + + const varExpr = firstToken.replace(/["']/g, '').replace(/^\$/, ''); + // ... rest of existing storeVar code ... +``` + +- [ ] **Step 3: Run type check** + +Run: `npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/define-macro.ts +git commit -m "feat: reject % transient variables in storeVar input macros (#137)" +``` + +--- + +### Task 10: Story API — Sigil Detection in `get()` / `set()` + +**Files:** + +- Modify: `src/story-api.ts:72-97,180-193` + +- [ ] **Step 1: Write failing test for Story.set('%var', val)** + +```typescript +it('Story.set routes % to transient', () => { + Story.set('%foo', 42); + expect(useStoryStore.getState().transient.foo).toBe(42); +}); + +it('Story.get routes % to transient', () => { + useStoryStore.getState().setTransient('bar', 'hello'); + expect(Story.get('%bar')).toBe('hello'); +}); + +it('Story.set bulk routes % keys to transient', () => { + Story.set({ '%a': 1, b: 2 }); + expect(useStoryStore.getState().transient.a).toBe(1); + expect(useStoryStore.getState().variables.b).toBe(2); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/unit/story-api.test.ts` +Expected: FAIL + +- [ ] **Step 3: Update Story.get() and Story.set()** + +In `src/story-api.ts`, update `get()` (lines 180-182): + +```typescript + get(name: string): unknown { + if (name.startsWith('%')) { + return useStoryStore.getState().transient[name.slice(1)]; + } + return useStoryStore.getState().variables[name]; + }, +``` + +Update `set()` (lines 184-193): + +```typescript + set(nameOrVars: string | Record, value?: unknown): void { + const state = useStoryStore.getState(); + if (typeof nameOrVars === 'string') { + if (nameOrVars.startsWith('%')) { + state.setTransient(nameOrVars.slice(1), value); + } else { + state.setVariable(nameOrVars, value); + } + } else { + for (const [k, v] of Object.entries(nameOrVars)) { + if (k.startsWith('%')) { + state.setTransient(k.slice(1), v); + } else { + state.setVariable(k, v); + } + } + } + }, +``` + +- [ ] **Step 4: Update variableChanged subscription to include transient** + +In `src/story-api.ts`, update `ensureVariableChangedSubscription()` (lines 72-97): + +```typescript +function ensureVariableChangedSubscription(): void { + if (variableChangedSubActive) return; + variableChangedSubActive = true; + let prevVars = { ...useStoryStore.getState().variables }; + let prevTrans = { ...useStoryStore.getState().transient }; + useStoryStore.subscribe((state) => { + const changed: Record = {}; + let hasChanges = false; + + // Check $variables + const allVarKeys = new Set([ + ...Object.keys(prevVars), + ...Object.keys(state.variables), + ]); + for (const key of allVarKeys) { + if (state.variables[key] !== prevVars[key]) { + changed[key] = { from: prevVars[key], to: state.variables[key] }; + hasChanges = true; + } + } + + // Check %transient + const allTransKeys = new Set([ + ...Object.keys(prevTrans), + ...Object.keys(state.transient), + ]); + for (const key of allTransKeys) { + if (state.transient[key] !== prevTrans[key]) { + changed[`%${key}`] = { from: prevTrans[key], to: state.transient[key] }; + hasChanges = true; + } + } + + prevVars = { ...state.variables }; + prevTrans = { ...state.transient }; + if (hasChanges) { + emit('variableChanged', changed); + } + }); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `npx vitest run test/unit/story-api.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/story-api.ts test/unit/story-api.test.ts +git commit -m "feat: route % sigil in Story.get/set to transient store (#137)" +``` + +--- + +### Task 11: Published Types — Add Transient to `types/index.d.ts` + +**Files:** + +- Modify: `types/index.d.ts` + +- [ ] **Step 1: Update StoryAPI type declarations** + +In `types/index.d.ts`, update the `get()` and `set()` method docs to mention `%` prefix for transient: + +Find the existing `get` and `set` method declarations in the `StoryAPI` interface and update their JSDoc: + +```typescript + /** + * Get a variable value. Use '%name' prefix for transient variables. + * @example Story.get('health') // $health + * @example Story.get('%npcList') // %npcList (transient) + */ + get(name: string): unknown; + + /** + * Set one or more variables. Use '%name' prefix for transient variables. + * @example Story.set('health', 100) + * @example Story.set('%npcList', [...]) + * @example Story.set({ health: 100, '%npcList': [...] }) + */ + set(name: string, value: unknown): void; + set(vars: Record): void; +``` + +- [ ] **Step 2: Run type check** + +Run: `npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add types/index.d.ts +git commit -m "docs: update published types for transient % sigil (#137)" +``` + +--- + +### Task 12: Documentation Updates + +**Files:** + +- Modify: `docs/variables.md` +- Modify: `docs/special-passages.md` +- Modify: `docs/story-api.md` +- Modify: `docs/markup.md` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Add Transient Variables section to docs/variables.md** + +After the "Temporary Variables" section (line 28), add: + +```markdown +## Transient Variables + +Transient variables start with `%` and persist across passage navigation like story variables, but are **excluded from all persistence** — history snapshots, save payloads, and session storage. They are ideal for large derived state that is fully re-derivable from an external engine. +``` + +{set %npcList = [...]} +{set %dashboardData = { revenue: 1000 }} + +``` + +Display them with `{%npcList}` or `{print %npcList}`. + +Transient variables are reactive — changes trigger Preact rerenders just like `$` variables. But unlike `$` variables, they don't bloat history snapshots or save files. + +### When to use transient variables + +- **Derived display state** projected from an external engine (NPC lists, stat sheets, economy dashboards) +- **UI state** that doesn't need to survive a save/load cycle (panel open/closed, scroll position) +- **Large data** that would cause excessive history growth if stored as `$` variables + +### The `StoryTransients` Passage + +Declare transient variables and their defaults in a special passage named `StoryTransients`: + +``` + +:: StoryTransients +%npcList = [] +%agents = {} +%economy_summary = {} + +``` + +These defaults are applied on `init()` and `restart()`, and after loading a save (since transient data is not saved). + +The `StoryTransients` passage is optional. Variable names must be unique across `$` and `%` scopes. + +### Lifecycle + +| Event | Behavior | +|-------|----------| +| Navigation | Persists (unlike `_temporary`) | +| Back / Forward | Stays at current value (not restored from history) | +| Restart | Reset to defaults | +| Save | Excluded | +| Load | Reset to defaults | +| Page refresh (F5) | Reset to defaults | +``` + +- [ ] **Step 2: Add StoryTransients to docs/special-passages.md** + +After the `StoryVariables` section, add: + +```markdown +## `StoryTransients` + +Declares transient variables with their default values. Each line must follow `%name = expression`: +``` + +:: StoryTransients +%npcList = [] +%agents = {} +%economy_summary = {} + +``` + +Transient variables are reactive but excluded from all persistence (history, saves, session storage). They reset to defaults on restart and load. + +Variable names must be unique across `StoryVariables` and `StoryTransients`. See [Variables](variables.md) for details. +``` + +- [ ] **Step 3: Update docs/story-api.md** + +Update the `Story.get(name)` and `Story.set(name, value)` sections to document `%` prefix: + +After the existing `Story.set` example: + +```markdown +#### Transient variables + +Prefix variable names with `%` to read/write transient variables: +``` + +{do} +Story.set("%npcList", [...]); +Story.set({ "%agents": {...}, health: 100 }); +var agents = Story.get("%agents"); +{/do} + +``` + +Transient variables fire `variableChanged` events with `%`-prefixed keys: + +``` + +Story.on("variableChanged", function(changed) { +// changed = { "%npcList": { from: [...], to: [...] }, health: { from: 90, to: 100 } } +}); + +``` + +``` + +- [ ] **Step 4: Update docs/markup.md** + +Update the "Variable Display" section (line 25) to include `%`: + +```markdown +## Variable Display + +Inline a variable's value using `{$name}`, `{_name}`, or `{%name}`: +``` + +Your health is {$health}. +Temporary result: {\_result}. +NPC count: {%npcList.length}. + +``` + +``` + +- [ ] **Step 5: Update docs/variables.md expression transforms section** + +Update the "Variable transforms" section (line 119-124) to include `%`: + +```markdown +- `$varName` into a reference to the story variable `varName` +- `_tempName` into a reference to the temporary variable `tempName` +- `@localName` into a reference to the block-scoped local `localName` +- `%transName` into a reference to the transient variable `transName` +``` + +- [ ] **Step 6: Update CHANGELOG.md** + +Add under `## [Unreleased]` → `### Added`: + +```markdown +- Transient variables (`%var`): reactive Zustand-backed variables that are excluded from all persistence (history snapshots, save payloads, session storage). Declared in a `StoryTransients` passage with `%name = value` syntax. Ideal for large derived state projected from external engines. Accessible via `{%var}` in passages, `{set %var = expr}`, and `Story.set('%var', value)` / `Story.get('%var')` in the API. ([#137](https://github.com/rohal12/spindle/issues/137)) +``` + +- [ ] **Step 7: Commit** + +```bash +git add docs/variables.md docs/special-passages.md docs/story-api.md docs/markup.md CHANGELOG.md +git commit -m "docs: add transient variables documentation and changelog (#137)" +``` + +--- + +### Task 13: Integration Tests + +**Files:** + +- Create: `test/e2e/transient-variables.test.ts` (or add to existing integration test file) + +- [ ] **Step 1: Write integration tests** + +Uses `happy-dom` DOM testing (same pattern as `test/dom/render.test.tsx`): + +```typescript +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach } from 'vitest'; +import { render } from 'preact'; +import { act } from 'preact/test-utils'; +import { tokenize } from '../../src/markup/tokenizer'; +import { buildAST } from '../../src/markup/ast'; +import { renderNodes } from '../../src/markup/render'; +import { useStoryStore } from '../../src/store'; +import type { StoryData, Passage } from '../../src/parser'; + +function makePassage(pid: number, name: string, content: string): Passage { + return { pid, name, tags: [], metadata: {}, content }; +} + +function makeStoryData(passages: Passage[], startNode = 1): StoryData { + const byName = new Map(passages.map((p) => [p.name, p])); + const byId = new Map(passages.map((p) => [p.pid, p])); + return { + name: 'Test', + startNode, + ifid: 'test', + format: 'spindle', + formatVersion: '0.1.0', + passages: byName, + passagesById: byId, + userCSS: '', + userScript: '', + }; +} + +function renderMarkup(markup: string): HTMLElement { + const tokens = tokenize(markup); + const ast = buildAST(tokens); + const container = document.createElement('div'); + render(<>{renderNodes(ast)}>, container); + return container; +} + +describe('transient variables integration', () => { + beforeEach(() => { + const store = useStoryStore.getState(); + store.init( + makeStoryData([ + makePassage(1, 'Start', 'Start'), + makePassage(2, 'Page2', '{%x}'), + ]), + { health: 100 }, + { x: 0, list: ['a', 'b', 'c'] }, + ); + }); + + it('{%var} displays transient value', () => { + useStoryStore.getState().setTransient('x', 42); + const el = renderMarkup('{%x}'); + expect(el.textContent).toBe('42'); + }); + + it('{set %x = 5} writes to transient store', () => { + const el = renderMarkup('{set %x = 5}{%x}'); + expect(el.textContent).toBe('5'); + expect(useStoryStore.getState().transient.x).toBe(5); + expect(useStoryStore.getState().variables).not.toHaveProperty('x'); + }); + + it('{unset %x} removes from transient', () => { + useStoryStore.getState().setTransient('x', 10); + const el = renderMarkup('{unset %x}{%x}'); + expect(el.textContent).toBe(''); + }); + + it('{if %x > 3} conditional with transient', () => { + useStoryStore.getState().setTransient('x', 5); + const el = renderMarkup('{if %x > 3}yes{else}no{/if}'); + expect(el.textContent).toContain('yes'); + }); + + it('{for @item of %list} iterates transient array', () => { + const el = renderMarkup('{for @item of %list}{@item}{/for}'); + expect(el.textContent).toContain('a'); + expect(el.textContent).toContain('b'); + expect(el.textContent).toContain('c'); + }); + + it('transient values survive navigation', () => { + act(() => { + useStoryStore.getState().setTransient('x', 42); + useStoryStore.getState().navigate('Page2'); + }); + expect(useStoryStore.getState().transient.x).toBe(42); + }); + + it('transient values stay current on goBack', () => { + act(() => { + useStoryStore.getState().navigate('Page2'); + useStoryStore.getState().setTransient('x', 99); + useStoryStore.getState().goBack(); + }); + expect(useStoryStore.getState().transient.x).toBe(99); + }); + + it('transient excluded from save payload', () => { + useStoryStore.getState().setTransient('x', { huge: 'data' }); + const payload = useStoryStore.getState().getSavePayload(); + expect(payload.variables).not.toHaveProperty('x'); + expect((payload as any).transient).toBeUndefined(); + }); + + it('transient resets to defaults on restart', () => { + useStoryStore.getState().setTransient('x', 99); + useStoryStore.getState().restart(); + expect(useStoryStore.getState().transient.x).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run integration tests** + +Run: `npx vitest run test/e2e/transient-variables.test.ts` +Expected: PASS + +- [ ] **Step 3: Run full test suite** + +Run: `npx vitest run` +Expected: PASS (no regressions) + +- [ ] **Step 4: Commit** + +```bash +git add test/e2e/transient-variables.test.ts +git commit -m "test: add transient variables integration tests (#137)" +``` + +--- + +### Task 14: Final Verification + +- [ ] **Step 1: Type check** + +Run: `npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 2: Full test suite** + +Run: `npx vitest run` +Expected: PASS + +- [ ] **Step 3: Build** + +Run: `npx vite build` +Expected: PASS diff --git a/docs/superpowers/specs/2026-03-27-computed-for-loop-fix-design.md b/docs/superpowers/specs/2026-03-27-computed-for-loop-fix-design.md new file mode 100644 index 0000000..e7c1f16 --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-computed-for-loop-fix-design.md @@ -0,0 +1,94 @@ +# Design: Fix infinite reactive loop when `{computed}` reads `@` locals inside `{for}` + +**Issue**: [#140](https://github.com/rohal12/spindle/issues/140) +**Date**: 2026-03-27 + +## Problem + +`{computed _x = @item.status}` inside `{for @item of array}` causes an infinite reactive loop that hangs the browser. + +### Root cause + +`ForIteration` in `For.tsx` creates a new `localState` object on every render (line 70): + +```ts +const localState = { ...parentValues, ...ownKeys, ...localMutations }; +``` + +This new reference propagates through: + +1. `LocalsValuesContext.Provider value={localState}` — new context value +2. `useMergedLocals()` in `{computed}` — returns new tuple (localsValues ref changed) +3. `useLayoutEffect` deps in `{computed}` — effect re-fires +4. `computeAndApply()` calls `setTemporary()` — store update +5. Store update triggers passage re-render → `For` re-renders → back to step 1 + +Additionally, `ownKeys` is created fresh inside `list.map()` on every render of `For` (lines 135-138), so even if `localState` were memoized, its `ownKeys` input would always be a new reference. + +## Solution + +Stabilize object references in `ForIteration` so downstream consumers don't see spurious changes. + +### Change 1: Pass loop variable values as individual props + +Instead of passing a pre-built `ownKeys` object from the parent `For` render (which is recreated every render), pass the individual values as props and memoize the object inside `ForIteration`: + +```tsx +// In For's render — pass primitives instead of object + + +// In ForIteration — memoize ownKeys from primitives +const ownKeys = useMemo( + () => ({ + [itemVar]: itemValue, + ...(indexVar ? { [indexVar]: indexValue } : undefined), + }), + [itemVar, itemValue, indexVar, indexValue], +); +``` + +Note: `itemValue` may be a non-primitive (object/array from the list). `useMemo` uses reference equality, so if the parent list is recreated with structurally-identical objects, `ownKeys` will still update — which is correct behavior (the list expression was re-evaluated and produced new objects). The key point is that when the re-render is triggered by an _unrelated_ store change (like `{computed}` writing to a temp), the list items keep their identity and `ownKeys` stays stable. + +### Change 2: Memoize `localState` + +```ts +const localState = useMemo( + () => ({ ...parentValues, ...ownKeys, ...localMutations }), + [parentValues, ownKeys, localMutations], +); +``` + +With `ownKeys` stabilized from Change 1, this memo only busts when actual values change. + +## Files changed + +- `src/components/macros/For.tsx` — both changes above +- `test/dom/render.test.tsx` (or new test file) — regression test + +## Testing + +Add a test that renders: + +```twee +{computed _items = [{name: 'a', status: 'ok'}, {name: 'b', status: 'err'}]} +{for @item of _items} +{computed _derived = @item.status} +{@item.name}-{_derived} +{/for} +``` + +Verify it renders `a-ok` and `b-err` without hanging. Use a timeout guard so the test fails fast rather than hanging if the fix doesn't work. + +## Out of scope + +- `{computed}` targeting `@` locals (not needed per discussion) +- Changes to `useMergedLocals` or `Computed.tsx` diff --git a/docs/superpowers/specs/2026-03-27-mutation-buffer-design.md b/docs/superpowers/specs/2026-03-27-mutation-buffer-design.md new file mode 100644 index 0000000..abeaf3c --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-mutation-buffer-design.md @@ -0,0 +1,126 @@ +# Mutation Buffer: Fix consecutive `{set}` visibility + +**Issue:** [#136](https://github.com/rohal12/spindle/issues/136) +**Date:** 2026-03-27 + +## Problem + +Consecutive `{set}` macros in a passage cannot see each other's variable mutations. Each `{set}` is a separate Preact component that calls `executeMutation()`, which snapshots the Zustand store via `useStoryStore.getState()`. The first `{set}` updates the store, but the second `{set}` does not see the update during the same render pass. + +This affects both `$vars` (story variables) and `_temps` (temporary variables). `@locals` are unaffected because they use a mutable ref-based mechanism (`LocalsUpdateContext`). + +`{if}` and `{for}` work after `{set}` because they subscribe to the store via `useMergedLocals()` and re-render when it changes. `{set}` has a `useRef(false)` guard preventing re-execution, so re-rendering doesn't help. + +### Reproduction + +``` +:: Test [nobr] +{set _x = [3, 1, 2]} +{set _y = _x.slice().sort()} +Result: {_y} +``` + +**Expected:** `Result: 1,2,3` +**Actual:** `TypeError: Cannot read properties of undefined (reading 'slice')` + +## Solution: MutationBufferContext + +A Preact context (`MutationBufferContext`) provides a mutable buffer that accumulates variable mutations within a render pass. `executeMutation()` reads from this buffer for subsequent calls, ensuring consecutive `{set}` macros see each other's changes. + +### New type and context + +Defined in `src/markup/render.tsx` alongside existing contexts (`LocalsUpdateContext`, `NobrContext`, etc.): + +```typescript +export interface MutationBuffer { + vars: Record; + temps: Record; + populated: boolean; +} + +export const MutationBufferContext = createContext(null); +``` + +- Default `null` preserves current behavior when no provider is present (e.g., unit tests). +- `populated` distinguishes "first mutation call" (read from store) from "subsequent calls" (read from buffer). + +### Modified: `execute-mutation.ts` + +`executeMutation()` accepts an optional `buffer: MutationBuffer | null` parameter. + +**Read strategy:** + +- If buffer is `null` or not `populated`: snapshot vars/temps from the Zustand store (current behavior). +- If buffer is `populated`: snapshot vars/temps from the buffer instead. + +**Write strategy (after `execute()`):** + +- Always update the Zustand store (so subscribed components like `{if}` re-render correctly). +- Also update the buffer with the full post-mutation snapshot and set `populated = true`. + +**Deletion handling:** +The deletion detection loop compares the post-execution clone against the original base (buffer if populated, store otherwise). The buffer is updated to reflect deletions. + +Data flow for two consecutive `{set}` macros: + +``` +First {set _x = [3,1,2]}: + buffer not populated → read from store (temporary = {}) + execute() → temps = {x: [3,1,2]} + store.setTemporary('x', [3,1,2]) + buffer.temps = {x: [3,1,2]}, buffer.populated = true + +Second {set _y = _x.slice().sort()}: + buffer populated → read from buffer (temps = {x: [3,1,2]}) + execute() → temps = {x: [3,1,2], y: [1,2,3]} + store.setTemporary('y', [1,2,3]) + buffer.temps = {x: [3,1,2], y: [1,2,3]} + +After render commit (useEffect): + buffer.vars = {}, buffer.temps = {}, buffer.populated = false +``` + +### Providers + +**`src/components/Passage.tsx`:** +Wraps the passage output in `MutationBufferContext.Provider`. Buffer created via `useRef` (stable identity across re-renders). A `useEffect` resets the buffer after each render commit. + +**`src/components/PassageDialog.tsx`:** +Same pattern — independent buffer for dialog content. + +**Inline passages (`{display}`, `{include}`):** +Rendered within the parent passage's component tree. They inherit the parent's `MutationBufferContext` via normal context propagation. Mutations in included passages are visible to later macros in the parent passage. + +### Wiring in `define-macro.ts` + +The `Wrapper` component reads the buffer via `useContext(MutationBufferContext)` and passes it through `ctx.mutate`: + +```typescript +mutate: (code: string) => executeMutation(code, getValues(), update, buffer), +``` + +The `MacroContext` interface gains no new fields — the buffer is an internal implementation detail, not exposed to macro authors. + +## Files changed + +| File | Change | +| ---------------------------------- | ------------------------------------------------------------------- | +| `src/markup/render.tsx` | Add `MutationBuffer` interface and `MutationBufferContext` | +| `src/execute-mutation.ts` | Accept buffer param, merge from buffer, write back to buffer | +| `src/define-macro.ts` | Read `MutationBufferContext`, pass to `executeMutation` | +| `src/components/Passage.tsx` | Provide `MutationBufferContext` with `useRef` + `useEffect` cleanup | +| `src/components/PassageDialog.tsx` | Provide `MutationBufferContext` | +| `test/unit/expression.test.ts` | Add tests for consecutive `executeMutation` calls with buffer | + +## Edge cases + +- **No provider (unit tests, external callers):** buffer is `null`, current store-only behavior preserved. +- **Re-renders:** buffer cleared after each render commit via `useEffect`. `{set}` macros don't re-execute (useRef guard), so cleared buffer is correct. +- **Multiple passages on screen:** each `Passage` component has its own buffer. No cross-passage interference. +- **PassageDone:** rendered in a deferred `useEffect`, after the buffer is cleared. Gets its own render pass; buffer re-populates if needed. + +## Out of scope + +- Changing how `@locals` work (already correct via mutable refs). +- Making `{set}` subscribe to the store (would require removing the useRef guard, risking infinite loops). +- Auto-batching multiple `{set}` calls at the AST level (over-engineered for this fix). diff --git a/docs/superpowers/specs/2026-03-27-transient-variables-design.md b/docs/superpowers/specs/2026-03-27-transient-variables-design.md new file mode 100644 index 0000000..8620deb --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-transient-variables-design.md @@ -0,0 +1,251 @@ +# Transient Variables — Design Spec + +**Issue:** #137 — Reactive variables excluded from history snapshots and saves +**Date:** 2026-03-27 +**Approach:** New `%` sigil with `StoryTransients` passage (Option A from issue, Approach A from brainstorm) + +## Problem + +Spindle's session persistence stores a full snapshot of all `$variables` at every history moment. Games that project large derived state into story variables for reactive display cause the session blob to grow linearly — ~21 KB per sync across 40 history entries produces ~840 KB of redundant data. This state is fully re-derivable from the engine and meaningless in history replay. + +Temporary variables (`_var`) don't solve this because they're cleared on every navigation, causing a flash of `undefined` between navigation and the next engine sync. + +## Design + +### New scope: transient (`%`) + +A 4th variable scope with sigil `%`: + +| Sigil | Scope | Lifetime | Persistence | +| ------ | ------------- | ------------------ | ----------------------- | +| `$var` | Story | Entire playthrough | History, saves, session | +| `_var` | Temporary | Current passage | None | +| `@var` | Local | Block (for/widget) | None | +| `%var` | **Transient** | **Entire session** | **None** | + +Transient variables are reactive (Zustand-backed, trigger Preact rerenders) but excluded from all persistence: history patches, save payloads, and session storage. + +### Declaration: `StoryTransients` passage + +A new special passage, parallel to `StoryVariables`: + +``` +:: StoryTransients +%npcList = [] +%agents = {} +%dossiers = {} +%economy_summary = {} +``` + +Uses the same declaration syntax as `StoryVariables` (`%name = expression`). Defaults are applied on `init()` and `restart()`. + +The `StoryTransients` passage is optional — absence simply means no transient variables are declared. + +### Twee-side usage + +Full support in all markup contexts: + +``` +{%npcList} → display value +{.red %health} → styled display +{set %uiState = "open"} → assignment +{set %x = 5; %y = %x + 1} → multiple assignments +{unset %oldFlag} → deletion +{if %agents.length > 0}...{/if} → conditionals +{for @agent of %agents}...{/for} → loops +{print %economy_summary.gdp} → expression evaluation +``` + +### Lifecycle + +| Event | Behavior | +| -------------------------- | ----------------------------------------------------- | +| `init()` | Deep-clone `transientDefaults` into `transient` | +| `navigate()` | No-op — transient values persist | +| `goBack()` / `goForward()` | No-op — transient values stay current (always "live") | +| `restart()` | Reset to `transientDefaults` | +| `persistSession()` | Excluded | +| `getSavePayload()` | Excluded | +| `loadSavePayload()` | Reset to defaults (engine re-syncs) | + +## Store Layer + +### New state fields + +```typescript +// In StoryState interface +transient: Record; +transientDefaults: Record; +``` + +### New actions + +```typescript +setTransient: (name: string, value: unknown) => void; +deleteTransient: (name: string) => void; +``` + +Both are Immer-wrapped, same pattern as `setVariable`. Changes to `transient` fire Zustand subscriptions for Preact reactivity. + +### What doesn't change + +Transient variables never participate in: + +- `variableBase` / `patchEntries` / `serializedHistory` (module-level patch state) +- `reconstructVarsAt(index)` (history replay) +- Any serialization path + +## Parser & Expression Engine + +### Expression transformation (`src/expression.ts`) + +New regex with negative lookbehind to avoid conflicting with the JS modulo operator (`%`): + +```typescript +const TRANS_RE = /(?, + temporary: Record, + locals: Record, + __fns: ExpressionFns, + transient: Record, +) => unknown; +``` + +### Tokenizer (`src/markup/tokenizer.ts`) + +`%` added to variable sigil recognition. `{%npcList}` produces a variable display token with sigil `%`. + +### Interpolation (`src/interpolation.ts`) + +Simple `{%var}` lookups resolve against the `transient` dict. Dot-path access (`{%obj.field.sub}`) works identically to `$` variables. + +### `useMergedLocals` hook + +Currently returns `[variables, temporary, locals]`. Extended to a 4-tuple: + +```typescript +[variables, temporary, locals, transient]; +``` + +All call sites that destructure this tuple or pass it to expression evaluation updated accordingly. + +### StoryTransients parsing + +Reuse the existing `parseStoryVariables` logic from `src/story-variables.ts`, accepting `%` sigil lines. Either parameterize the existing function with a sigil argument or create a thin wrapper. + +## Story API + +### Sigil detection in `Story.set()` / `Story.get()` + +```typescript +Story.set('%npcList', data); // routes to store.setTransient('npcList', data) +Story.set('health', 50); // routes to store.setVariable('health', 50) (unchanged) +Story.get('%npcList'); // reads from store.transient['npcList'] +Story.get('health'); // reads from store.variables['health'] (unchanged) +``` + +Bulk form also supported: + +```typescript +Story.set({ '%npcList': [...], '%agents': {...} }) +``` + +Keys starting with `%` route to transient; all others route to variables. + +### Event integration + +`Story.on('variableChanged', cb)` fires for transient changes too. Changed entries include the `%` prefix in the key name: + +```typescript +Story.on('variableChanged', (changed) => { + // changed = { '%npcList': [...], 'health': 50 } + // '%' prefix distinguishes transient from persistent +}); +``` + +## Macro Integration + +### `{set}` / `{unset}` + +Route `%` sigil assignments to `store.setTransient()` and `store.deleteTransient()`, same pattern as `$` → `setVariable` and `_` → temporary writes. + +### `defineMacro` feature flags + +- `merged` flag: `ctx.evaluate()` receives all four dicts including `transient`. No new flag needed. +- `storeVar` flag: **Rejects `%` sigil.** Input macros (`{textbox}`, `{numberbox}`, `{textarea}`, `{checkbox}`, `{radiobutton}`, `{cycle}`, `{listbox}`) cannot bind to transient variables. Attempting `{textbox "%foo"}` produces a `MacroError`. + +### `executeMutation` (`src/execute-mutation.ts`) + +`transient` dict added to the mutation scope so `{do}` blocks, `{button}` bodies, and other mutation contexts can read/write `%` variables. + +## Validation + +### Compile-time + +- `{%undeclared}` in a passage when `%undeclared` is not in `StoryTransients` — warning (same behavior as undeclared `$` variables) +- `$` declarations in `StoryTransients` passage — error +- `%` declarations in `StoryVariables` passage — error +- Same name declared as both `$foo` and `%foo` — error (names must be unique across scopes) + +### Runtime + +- `{set %foo = 5}` for undeclared `%foo` — allowed (dynamic creation, same as `$` variables) +- `{textbox "%foo"}` — `MacroError`: transient variables cannot be bound to input macros +- `{unset %foo}` — deletes key from transient dict + +## Testing + +### Unit tests + +| Area | Tests | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Expression engine | `%var` → `transient["var"]` transform, dot-path `%obj.field`, sigil inside string literals not transformed | +| Store | `setTransient` writes, transient survives `navigate()`, excluded from patches, excluded from `getSavePayload()`, excluded from `persistSession()`, reset on `restart()` | +| StoryTransients parsing | `%` declarations parsed, type inference, `$` in StoryTransients errors, `%` in StoryVariables errors, cross-scope name collision errors | +| Story API | `Story.set('%foo', val)` routes to transient, `Story.get('%foo')` reads transient, `variableChanged` fires with `%` prefix | + +### Integration tests + +| Area | Tests | +| ---------- | ----------------------------------------------------------------------------------------------- | +| Rendering | `{%var}` displays value, updates reactively on transient change | +| Macros | `{set %x = 5}`, `{unset %x}`, `{if %x > 3}`, `{for @item of %list}` | +| History | Navigate forward/back, verify transient values stay current (not restored to historical state) | +| Save/load | Save with transient data, load — transient resets to defaults, `$` variables restored correctly | +| Validation | `{textbox "%foo"}` produces MacroError | + +## Scope Boundaries + +### In scope + +- `%` sigil: tokenizer, expression engine, interpolation, display +- `StoryTransients` passage: parsing, defaults, init/restart +- Store: `transient` + `transientDefaults` dicts, setters +- Exclusion from all persistence paths +- Story API sigil detection for `set()`/`get()` +- `variableChanged` event with `%` prefix +- `storeVar` rejection for `%` sigil +- Compile-time validation warnings + +### Out of scope + +- Selective persistence (e.g. "persist in session but not saves") — excluded from all persistence +- `Story.transient()` config API — declaration is passage-based only +- Backward-compatible save migration — transient variables didn't exist before +- DevTools integration — future enhancement diff --git a/src/components/macros/Computed.tsx b/src/components/macros/Computed.tsx index 0af4da2..1b47f28 100644 --- a/src/components/macros/Computed.tsx +++ b/src/components/macros/Computed.tsx @@ -63,6 +63,7 @@ function computeAndApply( locals: Record, transient: Record, rawArgs: string, + prevRef: { current: unknown }, ): void { let newValue: unknown; try { @@ -75,8 +76,8 @@ function computeAndApply( return; } - const current = isTemp ? temporary[name] : variables[name]; - if (!valuesEqual(current, newValue)) { + if (!valuesEqual(prevRef.current, newValue)) { + prevRef.current = newValue; const state = useStoryStore.getState(); if (isTemp) state.setTemporary(name, newValue); else state.setVariable(name, newValue); @@ -104,6 +105,8 @@ defineMacro({ const isTemp = target.startsWith('_'); const name = target.slice(1); + const prevOutput = ctx.hooks.useRef(undefined); + const ran = ctx.hooks.useRef(false); if (!ran.current) { ran.current = true; @@ -116,6 +119,7 @@ defineMacro({ mergedLocals, mergedTrans, rawArgs, + prevOutput, ); } @@ -129,6 +133,7 @@ defineMacro({ mergedLocals, mergedTrans, rawArgs, + prevOutput, ); }, [mergedVars, mergedTemps, mergedLocals, mergedTrans]); diff --git a/src/components/macros/For.tsx b/src/components/macros/For.tsx index f05e0b4..69c1eec 100644 --- a/src/components/macros/For.tsx +++ b/src/components/macros/For.tsx @@ -53,21 +53,35 @@ function parseForArgs(rawArgs: string): { function ForIteration({ parentValues, - ownKeys, - initialValues, + itemVar, + itemValue, + indexVar, + indexValue, children, }: { parentValues: Record; - ownKeys: Record; - initialValues: Record; + itemVar: string; + itemValue: unknown; + indexVar: string | null; + indexValue: number; children: ASTNode[]; }) { const [localMutations, setLocalMutations] = useState>( - () => ({ ...initialValues }), + () => ({}), ); - // Recomputed every render — picks up new parentValues/ownKeys from parent - const localState = { ...parentValues, ...ownKeys, ...localMutations }; + const ownKeys = useMemo( + () => ({ + [itemVar]: itemValue, + ...(indexVar ? { [indexVar]: indexValue } : undefined), + }), + [itemVar, itemValue, indexVar, indexValue], + ); + + const localState = useMemo( + () => ({ ...parentValues, ...ownKeys, ...localMutations }), + [parentValues, ownKeys, localMutations], + ); const valuesRef = useRef(localState); valuesRef.current = localState; @@ -131,22 +145,17 @@ defineMacro({ ); } - const content = list.map((item, i) => { - const ownKeys: Record = { - [itemVar]: item, - ...(indexVar ? { [indexVar]: i } : undefined), - }; - - return ( - - ); - }); + const content = list.map((item, i) => ( + + )); return ctx.wrap(content); }, diff --git a/src/components/macros/WidgetInvocation.tsx b/src/components/macros/WidgetInvocation.tsx index 7ac3e58..e7149e4 100644 --- a/src/components/macros/WidgetInvocation.tsx +++ b/src/components/macros/WidgetInvocation.tsx @@ -190,8 +190,10 @@ function WidgetBody({ {}, ); - // Recomputed every render — picks up new ownKeys from parent - const localState = { ...parentValues, ...ownKeys, ...localMutations }; + const localState = useMemo( + () => ({ ...parentValues, ...ownKeys, ...localMutations }), + [parentValues, ownKeys, localMutations], + ); const valuesRef = useRef(localState); valuesRef.current = localState; @@ -233,10 +235,9 @@ export function WidgetInvocation({ } const argExprs = splitArgs(rawArgs); - const ownKeys: Record = {}; + const values: unknown[] = []; for (let i = 0; i < params.length; i++) { - const param = params[i]!; const expr = argExprs[i]; let value: unknown; if (expr !== undefined) { @@ -252,9 +253,20 @@ export function WidgetInvocation({ value = undefined; } } - ownKeys[param.startsWith('@') ? param.slice(1) : param] = value; + values.push(value); } + const ownKeys = useMemo(() => { + const keys: Record = {}; + for (let i = 0; i < params.length; i++) { + keys[params[i]!.startsWith('@') ? params[i]!.slice(1) : params[i]!] = + values[i]; + } + return keys; + // params is stable per widget instance; values tracks evaluated args + // eslint-disable-next-line react-hooks/exhaustive-deps + }, values); + return ( { document.body.removeChild(container); }); + + it('computed can derive from @local in single-iteration for-loop', () => { + useStoryStore + .getState() + .setTemporary('items', [{ name: 'a', status: 'ok' }]); + + const container = document.createElement('div'); + const passage = makePassage( + 1, + 'Test', + '{for @item of _items}{computed _derived = @item.status}{@item.name}-{_derived}{/for}', + ); + act(() => { + render(, container); + }); + + const results = container.querySelectorAll('.result'); + expect(results).toHaveLength(1); + expect(results[0].textContent).toBe('a-ok'); + }); + + it('computed reading @local inside for-loop does not infinite-loop (#140)', () => { + useStoryStore.getState().setTemporary('items', [ + { name: 'a', status: 'ok' }, + { name: 'b', status: 'err' }, + ]); + + const container = document.createElement('div'); + const passage = makePassage( + 1, + 'Test', + '{for @item of _items}{computed _derived = @item.status}{@item.name}-{_derived}{/for}', + ); + act(() => { + render(, container); + }); + + // Multiple iterations writing to the same _derived temp: last-write-wins, + // so both iterations see the final value. The key assertion is that it + // renders without hanging and produces the correct number of results. + const results = container.querySelectorAll('.result'); + expect(results).toHaveLength(2); + expect(results[0].textContent).toMatch(/^a-(ok|err)$/); + expect(results[1].textContent).toMatch(/^b-(ok|err)$/); + }); }); describe('{do}', () => {