From 7356fcbeb60bd229adf40f540e72c6654a48724f Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 12:52:46 +0800 Subject: [PATCH 01/12] feat: add % transient sigil to expression engine (#137) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/expression.ts | 27 ++++++++++++++----- test/unit/expression.test.ts | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/expression.ts b/src/expression.ts index 8a764c6..f0711c3 100644 --- a/src/expression.ts +++ b/src/expression.ts @@ -23,30 +23,34 @@ type CompiledExpression = ( temporary: Record, locals: Record, __fns: ExpressionFns, + transient: Record, ) => unknown; const FN_CACHE_MAX = 500; const fnCache = new Map(); /** - * Transform expression: $var → variables["var"], _var → temporary["var"] - * Only transforms when $ or _ appears as a word boundary (not inside strings naively, + * Transform expression: $var → variables["var"], _var → temporary["var"], + * @var → locals["var"], %var → transient["var"] + * Only transforms when sigils appear as a word boundary (not inside strings naively, * but authors already have full JS access so this is acceptable). */ const VAR_RE = /\$(\w+)/g; const TEMP_RE = /(?, 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()); + return fn(variables, temporary, locals, buildExpressionFns(), transient); } /** @@ -314,10 +320,11 @@ export function execute( variables: Record, temporary: Record, locals: Record = {}, + transient: Record = {}, ): void { const transformed = transform(code); const fn = getOrCompile('exec:' + transformed, transformed); - fn(variables, temporary, locals, buildExpressionFns()); + fn(variables, temporary, locals, buildExpressionFns(), transient); } /** @@ -332,5 +339,11 @@ export function clearExpressionCache(): void { } export function evaluateWithState(expr: string, state: StoryState): unknown { - return evaluate(expr, state.variables, state.temporary, {}); + return evaluate( + expr, + state.variables, + state.temporary, + {}, + (state as any).transient ?? {}, + ); } diff --git a/test/unit/expression.test.ts b/test/unit/expression.test.ts index 4488977..373da49 100644 --- a/test/unit/expression.test.ts +++ b/test/unit/expression.test.ts @@ -89,6 +89,39 @@ describe('evaluate', () => { it('still transforms _temp after operators', () => { expect(evaluate('1 + _x', {}, { x: 5 })).toBe(6); }); + + 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); + }); }); describe('execute', () => { @@ -208,6 +241,25 @@ describe('execute', () => { execute('$total = $total + @item', vars, {}, locals); expect(vars.total).toBe(10); }); + + 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); + }); }); describe('expression tracking functions', () => { From 96c0b9d5892242bfc077c951f9754da832ce924e Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 12:59:09 +0800 Subject: [PATCH 02/12] feat: add transient + transientDefaults to store (#137) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/expression.ts | 8 +------- src/store.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/expression.ts b/src/expression.ts index f0711c3..30697a5 100644 --- a/src/expression.ts +++ b/src/expression.ts @@ -339,11 +339,5 @@ export function clearExpressionCache(): void { } export function evaluateWithState(expr: string, state: StoryState): unknown { - return evaluate( - expr, - state.variables, - state.temporary, - {}, - (state as any).transient ?? {}, - ); + return evaluate(expr, state.variables, state.temporary, {}, state.transient); } diff --git a/src/store.ts b/src/store.ts index 43bee61..bd36d66 100644 --- a/src/store.ts +++ b/src/store.ts @@ -42,6 +42,7 @@ const SPECIAL_PASSAGES = new Set([ 'StoryInit', 'StoryInterface', 'StoryVariables', + 'StoryTransients', 'StoryLoading', 'SaveTitle', 'PassageReady', @@ -237,6 +238,8 @@ export interface StoryState { currentPassage: string; variables: Record; variableDefaults: Record; + transient: Record; + transientDefaults: Record; temporary: Record; history: HistoryMoment[]; historyIndex: number; @@ -256,6 +259,7 @@ export interface StoryState { init: ( storyData: StoryData, variableDefaults?: Record, + transientDefaults?: Record, ) => void; navigate: (passageName: string) => void; goBack: () => void; @@ -264,6 +268,8 @@ export interface StoryState { setTemporary: (name: string, value: unknown) => void; deleteVariable: (name: string) => void; deleteTemporary: (name: string) => void; + setTransient: (name: string, value: unknown) => void; + deleteTransient: (name: string) => void; trackRender: (passageName: string) => void; restart: () => void; save: (slot?: string, custom?: Record) => void; @@ -291,6 +297,8 @@ export const useStoryStore = create()( currentPassage: '', variables: {}, variableDefaults: {}, + transient: {}, + transientDefaults: {}, temporary: {}, history: [], historyIndex: -1, @@ -315,6 +323,7 @@ export const useStoryStore = create()( init: ( storyData: StoryData, variableDefaults: Record = {}, + transientDefaults: Record = {}, ) => { const startPassage = storyData.passagesById.get(storyData.startNode); if (!startPassage) { @@ -331,6 +340,8 @@ export const useStoryStore = create()( state.currentPassage = startPassage.name; state.variables = initialVars; state.variableDefaults = variableDefaults; + state.transient = deepClone(transientDefaults); + state.transientDefaults = transientDefaults; state.temporary = {}; state.history = [ { @@ -516,6 +527,18 @@ export const useStoryStore = create()( }); }, + setTransient: (name: string, value: unknown) => { + set((state) => { + state.transient[name] = value; + }); + }, + + deleteTransient: (name: string) => { + set((state) => { + delete state.transient[name]; + }); + }, + trackRender: (passageName: string) => { set((state) => { state.renderCounts[passageName] = @@ -524,7 +547,7 @@ export const useStoryStore = create()( }, restart: () => { - const { storyData, variableDefaults } = get(); + const { storyData, variableDefaults, transientDefaults } = get(); if (!storyData) return; const startPassage = storyData.passagesById.get(storyData.startNode); @@ -551,6 +574,7 @@ export const useStoryStore = create()( set((state) => { state.currentPassage = startPassage.name; state.variables = initialVars; + state.transient = deepClone(transientDefaults); state.temporary = {}; state.history = [ { @@ -804,6 +828,7 @@ export const useStoryStore = create()( state.visitCounts = payload.visitCounts ?? {}; state.renderCounts = payload.renderCounts ?? {}; state.temporary = {}; + state.transient = deepClone(get().transientDefaults); }); lastNavigationVars = get().variables; From 81812dd957e6853e948be5bf172351de68c9d443 Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 13:02:13 +0800 Subject: [PATCH 03/12] feat: add StoryTransients passage parsing (#137) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.tsx | 23 ++++++++++++++++++++++- src/story-variables.ts | 20 +++++++++++++------- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 8f756e3..6f5bfdd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -88,6 +88,27 @@ function boot() { const schema = parseStoryVariables(storyVarsPassage.content); const errors = validatePassages(storyData.passages, schema); + // 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); + } + if (errors.length > 0) { const root = document.getElementById('root'); if (root) renderErrors(root, errors); @@ -98,7 +119,7 @@ function boot() { defaults = extractDefaults(schema); - useStoryStore.getState().init(storyData, defaults); + useStoryStore.getState().init(storyData, defaults, transientDefaults); // Enter runtime phase — handlers registered from here on are cleaned on restart enterRuntimePhase(); diff --git a/src/story-variables.ts b/src/story-variables.ts index a58841f..f5a7327 100644 --- a/src/story-variables.ts +++ b/src/story-variables.ts @@ -12,7 +12,10 @@ export interface VariableSchema extends FieldSchema { default: unknown; } -const DECLARATION_RE = /^\$(\w+)\s*=\s*(.+)$/; +function declarationRegex(sigil: string): RegExp { + const escaped = sigil.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^${escaped}(\\w+)\\s*=\\s*(.+)$`); +} const VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g; const FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g; @@ -39,13 +42,16 @@ function inferSchema(value: unknown): FieldSchema { } /** - * Parse a StoryVariables passage content into a schema map. - * Each line: `$varName = expression` + * Parse a StoryVariables or StoryTransients passage content into a schema map. + * Each line: `$varName = expression` (or `%varName = expression` for transients) */ 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(); @@ -54,7 +60,7 @@ export function parseStoryVariables( const match = line.match(DECLARATION_RE); if (!match) { throw new Error( - `StoryVariables: Invalid declaration: "${line}". Expected: $name = value`, + `${passageName}: Invalid declaration: "${line}". Expected: ${sigil}name = value`, ); } @@ -64,7 +70,7 @@ export function parseStoryVariables( value = new Function('return (' + expr + ')')(); } catch (err) { throw new Error( - `StoryVariables: Failed to evaluate "$${name} = ${expr}": ${err instanceof Error ? err.message : err}`, + `${passageName}: Failed to evaluate "${sigil}${name} = ${expr}": ${err instanceof Error ? err.message : err}`, ); } @@ -146,8 +152,8 @@ export function validatePassages( const errors: string[] = []; for (const [name, passage] of passages) { - // Don't validate the StoryVariables passage itself - if (name === 'StoryVariables') continue; + // Don't validate the StoryVariables/StoryTransients passages themselves + if (name === 'StoryVariables' || name === 'StoryTransients') continue; const forLocals = extractForLocals(passage.content); From 389651bf71d47849629da4138e38d4ba1bbd4443 Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 13:04:44 +0800 Subject: [PATCH 04/12] feat: add % sigil to tokenizer and AST (#137) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/macros/VarDisplay.tsx | 6 +- src/markup/ast.ts | 2 +- src/markup/tokenizer.ts | 87 +++++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/components/macros/VarDisplay.tsx b/src/components/macros/VarDisplay.tsx index a4f178a..5016059 100644 --- a/src/components/macros/VarDisplay.tsx +++ b/src/components/macros/VarDisplay.tsx @@ -5,7 +5,7 @@ import { useInterpolate } from '../../hooks/use-interpolate'; interface VarDisplayProps { name: string; - scope: 'variable' | 'temporary' | 'local'; + scope: 'variable' | 'temporary' | 'local' | 'transient'; className?: string; id?: string; } @@ -22,7 +22,9 @@ export function VarDisplay({ name, scope, className, id }: VarDisplayProps) { ? s.variables[root] : scope === 'temporary' ? s.temporary[root] - : undefined, + : scope === 'transient' + ? s.transient[root] + : undefined, ); let value: unknown; diff --git a/src/markup/ast.ts b/src/markup/ast.ts index a31dac8..97dcc29 100644 --- a/src/markup/ast.ts +++ b/src/markup/ast.ts @@ -8,7 +8,7 @@ export interface TextNode { export interface VariableNode { type: 'variable'; name: string; - scope: 'variable' | 'temporary' | 'local'; + scope: 'variable' | 'temporary' | 'local' | 'transient'; className?: string; id?: string; } diff --git a/src/markup/tokenizer.ts b/src/markup/tokenizer.ts index 7b25c6d..e36b4ec 100644 --- a/src/markup/tokenizer.ts +++ b/src/markup/tokenizer.ts @@ -29,7 +29,7 @@ export interface MacroToken { export interface VariableToken { type: 'variable'; name: string; - scope: 'variable' | 'temporary' | 'local'; + scope: 'variable' | 'temporary' | 'local' | 'transient'; className?: string; id?: string; start: number; @@ -499,6 +499,51 @@ export function tokenize(input: string): Token[] { continue; } + 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_pct = scanBalancedBrace(input, nameStart); + if (closeIdx_pct !== -1) { + const expression = input.slice(afterSelectors, closeIdx_pct); + i = closeIdx_pct + 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; + } + if (charAfter !== undefined && /[a-zA-Z]/.test(charAfter)) { // {.class#id macroName args} i = afterSelectors; @@ -663,6 +708,46 @@ export function tokenize(input: string): Token[] { continue; } + // {%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; + } + // {macro ...} or {/macro} — but not bare { that's just text // Must start with a letter or / if ( From 02f44146823af4ba88ba11df5c60299603ba2682 Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 13:06:49 +0800 Subject: [PATCH 05/12] feat: add % transient support to rendering and interpolation (#137) - render.tsx: getVariableTextValue resolves transient scope for markdown - interpolation.ts: INTERP_TEST, resolveSimple, interpolate, and interpolateExpression all handle % sigil and transient namespace - VarDisplay.tsx already handled by Task 4 (no changes needed) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/interpolation.ts | 25 ++++++++++++++++++------- src/markup/render.tsx | 1 + 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/interpolation.ts b/src/interpolation.ts index b5b05db..3c5eb83 100644 --- a/src/interpolation.ts +++ b/src/interpolation.ts @@ -1,7 +1,7 @@ import { evaluate } from './expression'; -/** Detects any {…} block that starts with a sigil ($, _, @). */ -const INTERP_TEST = /\{[\$_@]\w/; +/** Detects any {…} block that starts with a sigil ($, _, @, %). */ +const INTERP_TEST = /\{[\$_@%]\w/; export function hasInterpolation(s: string): boolean { return INTERP_TEST.test(s); @@ -25,8 +25,9 @@ export function interpolateExpression( variables: Record, temporary: Record, locals: Record, + transient: Record = {}, ): string { - const value = evaluate(expr, variables, temporary, locals); + const value = evaluate(expr, variables, temporary, locals, transient); return value == null ? '' : String(value); } @@ -35,6 +36,7 @@ function resolveSimple( variables: Record, temporary: Record, locals: Record, + transient: Record, ): string { const prefix = ref[0]!; const path = ref.slice(1); @@ -46,6 +48,8 @@ function resolveSimple( value = variables[root]; } else if (prefix === '_') { value = temporary[root]; + } else if (prefix === '%') { + value = transient[root]; } else { value = locals[root]; } @@ -62,6 +66,7 @@ export function interpolate( variables: Record, temporary: Record, locals: Record, + transient: Record = {}, ): string { // Manual scan: process {…} blocks containing sigils. // Simple dot-path refs use the fast resolver; everything else falls back @@ -86,7 +91,7 @@ export function interpolate( i++; // skip { const sigil = template[i]; - if (sigil !== '$' && sigil !== '_' && sigil !== '@') { + if (sigil !== '$' && sigil !== '_' && sigil !== '@' && sigil !== '%') { // Not an interpolation — emit the { as text result += '{'; continue; @@ -111,10 +116,16 @@ export function interpolate( i = j + 1; // skip past closing } // Try simple dot-path match first - if (/^[\$_@][\w.]+$/.test(inner)) { - result += resolveSimple(inner, variables, temporary, locals); + if (/^[\$_@%][\w.]+$/.test(inner)) { + result += resolveSimple(inner, variables, temporary, locals, transient); } else { - result += interpolateExpression(inner, variables, temporary, locals); + result += interpolateExpression( + inner, + variables, + temporary, + locals, + transient, + ); } } diff --git a/src/markup/render.tsx b/src/markup/render.tsx index 7e51003..31f725b 100644 --- a/src/markup/render.tsx +++ b/src/markup/render.tsx @@ -243,6 +243,7 @@ function getVariableTextValue( let value: unknown; 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]; for (let i = 1; i < parts.length; i++) { From 223c0a32f1b9b652bc1c891f2ef8a2348455126c Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 13:08:49 +0800 Subject: [PATCH 06/12] feat: propagate transient through hooks, macros, and mutations (#137) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/define-macro.ts | 3 ++- src/execute-mutation.ts | 13 ++++++++++++- src/hooks/use-interpolate.ts | 6 +++--- src/hooks/use-merged-locals.ts | 10 ++++++---- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/define-macro.ts b/src/define-macro.ts index 2e6a967..ec6d391 100644 --- a/src/define-macro.ts +++ b/src/define-macro.ts @@ -52,6 +52,7 @@ export interface MacroContext { Record, Record, Record, + Record, ]; varName?: string; value?: unknown; @@ -178,7 +179,7 @@ export function defineMacro( ctx.merged = useMergedLocals(); const merged = ctx.merged; ctx.evaluate = (expr: string) => - evaluate(expr, merged[0], merged[1], merged[2]); + evaluate(expr, merged[0], merged[1], merged[2], merged[3]); } if (config.storeVar) { diff --git a/src/execute-mutation.ts b/src/execute-mutation.ts index 6f814bd..ee8aa0f 100644 --- a/src/execute-mutation.ts +++ b/src/execute-mutation.ts @@ -10,9 +10,10 @@ export function executeMutation( 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); + execute(code, vars, temps, localsClone, trans); for (const key of Object.keys(vars)) { if (vars[key] !== state.variables[key]) { @@ -24,6 +25,11 @@ export function executeMutation( 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]); @@ -41,6 +47,11 @@ export function executeMutation( 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); diff --git a/src/hooks/use-interpolate.ts b/src/hooks/use-interpolate.ts index 054d56f..a048613 100644 --- a/src/hooks/use-interpolate.ts +++ b/src/hooks/use-interpolate.ts @@ -5,13 +5,13 @@ import { hasInterpolation, interpolate } from '../interpolation'; export function useInterpolate(): ( s: string | undefined, ) => string | undefined { - const [variables, temporary, locals] = useMergedLocals(); + 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); + return interpolate(s, variables, temporary, locals, transient); }, - [variables, temporary, locals], + [variables, temporary, locals, transient], ); } diff --git a/src/hooks/use-merged-locals.ts b/src/hooks/use-merged-locals.ts index 910d6d8..05f32c6 100644 --- a/src/hooks/use-merged-locals.ts +++ b/src/hooks/use-merged-locals.ts @@ -3,19 +3,21 @@ import { useStoryStore } from '../store'; import { LocalsValuesContext } from '../markup/render'; /** - * Return store variables, temporary, and locals from context. - * All three dicts use unprefixed keys suitable for evaluate/execute. + * Return store variables, temporary, locals from context, and transient. + * All four dicts use unprefixed keys suitable for evaluate/execute. */ 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] as const; - }, [variables, temporary, localsValues]); + return [variables, temporary, localsValues, transient] as const; + }, [variables, temporary, localsValues, transient]); } From 9a6c5ee60de5fc9b3d980168dac8c1531ab7c3a4 Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 13:10:10 +0800 Subject: [PATCH 07/12] feat: add % routing to {unset} and reject % in storeVar macros (#137) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/macros/Unset.tsx | 4 +++- src/define-macro.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/macros/Unset.tsx b/src/components/macros/Unset.tsx index 7b38a07..167e0d5 100644 --- a/src/components/macros/Unset.tsx +++ b/src/components/macros/Unset.tsx @@ -15,11 +15,13 @@ defineMacro({ 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, or @name), got "${name}"`, + `spindle: {unset} expects a variable ($name, _name, %name, or @name), got "${name}"`, ); } } diff --git a/src/define-macro.ts b/src/define-macro.ts index ec6d391..87bb186 100644 --- a/src/define-macro.ts +++ b/src/define-macro.ts @@ -185,6 +185,15 @@ export function defineMacro( 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(/^\$/, ''); const segments = varExpr.split('.'); ctx.varName = varExpr; From e5e675730c8221fb959a20d96133fd6346d057ea Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 13:11:48 +0800 Subject: [PATCH 08/12] feat: route % sigil in Story.get/set to transient store (#137) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/story-api.ts | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/story-api.ts b/src/story-api.ts index 4e3e06c..d2f3c55 100644 --- a/src/story-api.ts +++ b/src/story-api.ts @@ -76,20 +76,37 @@ 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; - const allKeys = new Set([ + + // Check $variables + const allVarKeys = new Set([ ...Object.keys(prevVars), ...Object.keys(state.variables), ]); - for (const key of allKeys) { + 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); } @@ -178,16 +195,27 @@ export interface StoryAPI { function createStoryAPI(): StoryAPI { return { get(name: string): unknown { + if (name.startsWith('%')) { + return useStoryStore.getState().transient[name.slice(1)]; + } return useStoryStore.getState().variables[name]; }, set(nameOrVars: string | Record, value?: unknown): void { const state = useStoryStore.getState(); if (typeof nameOrVars === 'string') { - state.setVariable(nameOrVars, value); + if (nameOrVars.startsWith('%')) { + state.setTransient(nameOrVars.slice(1), value); + } else { + state.setVariable(nameOrVars, value); + } } else { for (const [k, v] of Object.entries(nameOrVars)) { - state.setVariable(k, v); + if (k.startsWith('%')) { + state.setTransient(k.slice(1), v); + } else { + state.setVariable(k, v); + } } } }, From 7934a254d2cbae97e7df0b5ca38705babf4052eb Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 13:14:29 +0800 Subject: [PATCH 09/12] docs: add transient variables documentation and changelog (#137) Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + docs/markup.md | 5 ++-- docs/special-passages.md | 15 ++++++++++++ docs/story-api.md | 20 ++++++++++++++++ docs/variables.md | 52 +++++++++++++++++++++++++++++++++++++--- types/index.d.ts | 14 ++++++++--- 6 files changed, 99 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be74302..8dcd84e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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)) - `Story.on('storyinit', callback)` event that fires after `StoryInit` completes — on initial boot and after every `restart()` call (including `Story.storage.clearGameData()` and `Story.storage.clearAllData()`). Allows external state engines to reliably re-sync after a restart. ([#115](https://github.com/rohal12/spindle/issues/115)) - Tooling API: `Story.getMacroRegistry()` returns metadata for all registered macros (built-in and user-defined) — name, block status, sub-macros, feature flags, source origin, and optional description/parameters - `@rohal12/spindle/tooling` entry point for Node.js/LSP use — lightweight `defineMacro()` shim that captures metadata without Preact, pre-loaded with builtin metadata from build-time JSON diff --git a/docs/markup.md b/docs/markup.md index 41a19e3..dcc7543 100644 --- a/docs/markup.md +++ b/docs/markup.md @@ -24,11 +24,12 @@ All four forms navigate to `Target` when clicked. The first form uses the passag ## Variable Display -Inline a variable's value using `{$name}` or `{_name}`: +Inline a variable's value using `{$name}`, `{_name}`, or `{%name}`: ``` Your health is {$health}. Temporary result: {_result}. +NPC count: {%npcList.length}. ``` Dot notation accesses nested fields: @@ -113,7 +114,7 @@ Void tags (`br`, `col`, `hr`, `img`, `wbr`) are self-closing. All other tags req ``` -Variable references (`{$var}`, `{_var}`, `{@var}`) inside HTML attributes are interpolated at render time: +Variable references (`{$var}`, `{_var}`, `{@var}`, `{%var}`) inside HTML attributes are interpolated at render time: ``` {set $color = "red"} diff --git a/docs/special-passages.md b/docs/special-passages.md index f022b2e..c04875e 100644 --- a/docs/special-passages.md +++ b/docs/special-passages.md @@ -38,6 +38,21 @@ When this passage exists, Spindle validates every `$variable` reference in your See [Variables](variables.md) for details. +## `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. + ## `StoryInterface` Controls the entire page layout. When this passage exists, its content replaces the default UI — including the menubar and passage display area. Use the `{passage}` macro to place the current passage within your custom layout. diff --git a/docs/story-api.md b/docs/story-api.md index 1944322..e4cc8cf 100644 --- a/docs/story-api.md +++ b/docs/story-api.md @@ -25,6 +25,26 @@ Set one or more story variables. {/do} ``` +#### 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 } } +}); +``` + ### `Story.goto(passageName)` Navigate to a passage. diff --git a/docs/variables.md b/docs/variables.md index f12c423..a12506f 100644 --- a/docs/variables.md +++ b/docs/variables.md @@ -1,6 +1,6 @@ # Variables -Spindle has three kinds of variables: **story variables** that persist across passages, **temporary variables** that reset on each navigation, and **local variables** that are scoped to a block (for-loop or widget body). +Spindle has four kinds of variables: **story variables** that persist across passages, **temporary variables** that reset on each navigation, **local variables** that are scoped to a block (for-loop or widget body), and **transient variables** that are reactive but excluded from persistence. ## Story Variables @@ -27,6 +27,51 @@ Display them with `{_temp}` or `{print _temp}`. Use temporary variables for intermediate calculations that don't need to persist. +## 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 | + ## The `StoryVariables` Passage Declare all story variables and their default values in a special passage named `StoryVariables`. Each line follows the format `$name = value`: @@ -106,7 +151,7 @@ Then use methods and getters in your passages: ## Expressions -Anywhere Spindle expects a value (conditions in `{if}`, values in `{set}`, arguments to `{print}`), you write JavaScript expressions with `$var`, `_var`, and `@var` placeholders: +Anywhere Spindle expects a value (conditions in `{if}`, values in `{set}`, arguments to `{print}`), you write JavaScript expressions with `$var`, `_var`, `@var`, and `%var` placeholders: ``` {if $health > 0 && $character.alive} @@ -121,10 +166,11 @@ Before evaluation, the expression system transforms: - `$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` Standard JavaScript operators and built-in functions (`Math`, `Array` methods, string methods) all work. -Variable sigils inside string literals are preserved as-is. This means `$`, `_`, and `@` inside quoted strings won't be transformed: +Variable sigils inside string literals are preserved as-is. This means `$`, `_`, `@`, and `%` inside quoted strings won't be transformed: ``` {set $greeting = "Hello, " + $name} diff --git a/types/index.d.ts b/types/index.d.ts index ac2d925..399519e 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -364,12 +364,20 @@ export interface SaveInfo { * @see {@link ../../src/story-api.ts} for the implementation. */ export interface StoryAPI { - /** Get the value of a story variable. */ + /** + * 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 a single story variable. */ + /** + * 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 multiple story variables at once. */ set(vars: Record): void; /** Navigate to a passage by name. */ From c7495b0bc6ec44c0499a8d10e97484a5385ef479 Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 13:16:25 +0800 Subject: [PATCH 10/12] test: add transient variables integration tests (#137) Co-Authored-By: Claude Opus 4.6 (1M context) --- test/dom/transient-variables.test.tsx | 113 ++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 test/dom/transient-variables.test.tsx diff --git a/test/dom/transient-variables.test.tsx b/test/dom/transient-variables.test.tsx new file mode 100644 index 0000000..fab01a8 --- /dev/null +++ b/test/dom/transient-variables.test.tsx @@ -0,0 +1,113 @@ +// @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); + }); +}); From 184776f3d2b8274bbf2c1218209b0db0f70c66f0 Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 13:23:26 +0800 Subject: [PATCH 11/12] fix: pass transient to all evaluate() callsites (#137) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/macros/Computed.tsx | 9 ++++++--- src/components/macros/ExprDisplay.tsx | 9 ++++++++- src/components/macros/WidgetInvocation.tsx | 14 +++++++++++--- src/story-variables.ts | 11 +++++++++-- src/triggers.ts | 8 +++++++- 5 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/components/macros/Computed.tsx b/src/components/macros/Computed.tsx index 79fe6d3..0af4da2 100644 --- a/src/components/macros/Computed.tsx +++ b/src/components/macros/Computed.tsx @@ -61,11 +61,12 @@ function computeAndApply( variables: Record, temporary: Record, locals: Record, + transient: Record, rawArgs: string, ): void { let newValue: unknown; try { - newValue = evaluate(expr, variables, temporary, locals); + newValue = evaluate(expr, variables, temporary, locals, transient); } catch (err) { console.error( `spindle: Error in {computed ${rawArgs}}${currentSourceLocation()}:`, @@ -86,7 +87,7 @@ defineMacro({ name: 'computed', merged: true, render({ rawArgs }, ctx) { - const [mergedVars, mergedTemps, mergedLocals] = ctx.merged!; + const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = ctx.merged!; let target: string; let expr: string; @@ -113,6 +114,7 @@ defineMacro({ mergedVars, mergedTemps, mergedLocals, + mergedTrans, rawArgs, ); } @@ -125,9 +127,10 @@ defineMacro({ mergedVars, mergedTemps, mergedLocals, + mergedTrans, rawArgs, ); - }, [mergedVars, mergedTemps, mergedLocals]); + }, [mergedVars, mergedTemps, mergedLocals, mergedTrans]); return null; }, diff --git a/src/components/macros/ExprDisplay.tsx b/src/components/macros/ExprDisplay.tsx index d6e614e..4d8ebae 100644 --- a/src/components/macros/ExprDisplay.tsx +++ b/src/components/macros/ExprDisplay.tsx @@ -17,10 +17,17 @@ export function ExprDisplay({ expression, className, id }: ExprDisplayProps) { const localsValues = useContext(LocalsValuesContext); const variables = useStoryStore((s) => s.variables); const temporary = useStoryStore((s) => s.temporary); + const transient = useStoryStore((s) => s.transient); let display: string; try { - const value = evaluate(expression, variables, temporary, localsValues); + const value = evaluate( + expression, + variables, + temporary, + localsValues, + transient, + ); display = value == null ? '' : String(value); } catch { display = `{error: ${expression}}`; diff --git a/src/components/macros/WidgetInvocation.tsx b/src/components/macros/WidgetInvocation.tsx index c7b5530..7ac3e58 100644 --- a/src/components/macros/WidgetInvocation.tsx +++ b/src/components/macros/WidgetInvocation.tsx @@ -32,7 +32,8 @@ function isStandaloneValue(token: string): boolean { // Quoted string if (first === '"' || first === "'" || first === '`') return true; // Variable ($var, _var, @var) - if (first === '$' || first === '_' || first === '@') return true; + if (first === '$' || first === '_' || first === '@' || first === '%') + return true; // Number literal if (/\d/.test(first)) return true; // Signed number (-1, +2) @@ -218,7 +219,8 @@ export function WidgetInvocation({ }: WidgetInvocationProps) { const parentValues = useContext(LocalsValuesContext); const nobr = useContext(NobrContext); - const [mergedVars, mergedTemps, mergedLocals] = useMergedLocals(); + const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = + useMergedLocals(); const childrenValue = invocationChildren?.length ? invocationChildren : null; @@ -239,7 +241,13 @@ export function WidgetInvocation({ let value: unknown; if (expr !== undefined) { try { - value = evaluate(expr, mergedVars, mergedTemps, mergedLocals); + value = evaluate( + expr, + mergedVars, + mergedTemps, + mergedLocals, + mergedTrans, + ); } catch { value = undefined; } diff --git a/src/story-variables.ts b/src/story-variables.ts index f5a7327..7e8ed98 100644 --- a/src/story-variables.ts +++ b/src/story-variables.ts @@ -35,7 +35,7 @@ function inferSchema(value: unknown): FieldSchema { const jsType = typeof value; if (!VALID_VAR_TYPES.has(jsType)) { throw new Error( - `StoryVariables: Unsupported type "${jsType}" for value ${String(value)}. Expected number, string, boolean, array, or object.`, + `Unsupported type "${jsType}" for value ${String(value)}. Expected number, string, boolean, array, or object.`, ); } return { type: jsType as VarType }; @@ -74,7 +74,14 @@ export function parseStoryVariables( ); } - const fieldSchema = inferSchema(value); + let fieldSchema: FieldSchema; + try { + fieldSchema = inferSchema(value); + } catch (err) { + throw new Error( + `${passageName}: ${err instanceof Error ? err.message : err}`, + ); + } schema.set(name, { ...fieldSchema, name, default: value }); } diff --git a/src/triggers.ts b/src/triggers.ts index f8fbfd8..c849af9 100644 --- a/src/triggers.ts +++ b/src/triggers.ts @@ -45,7 +45,13 @@ let dialogHostCallbacks: DialogHostCallbacks | null = null; function evalCondition(condition: string): boolean { const state = useStoryStore.getState(); try { - return !!evaluate(condition, state.variables, state.temporary); + return !!evaluate( + condition, + state.variables, + state.temporary, + {}, + state.transient, + ); } catch { return false; } From c8f3db966411099b962f91ecadfc88f868515b8c Mon Sep 17 00:00:00 2001 From: clem Date: Fri, 27 Mar 2026 13:29:59 +0800 Subject: [PATCH 12/12] fix: sync MacroContext.merged 4-tuple in published types (#137) Co-Authored-By: Claude Opus 4.6 (1M context) --- types/index.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/types/index.d.ts b/types/index.d.ts index 399519e..005b3a0 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -294,6 +294,7 @@ export interface MacroContext { Record, Record, Record, + Record, ]; varName?: string; value?: unknown;