@rohal12/spindle 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/package.json +5 -2
  3. package/src/action-registry.ts +83 -0
  4. package/src/automation/index.ts +11 -0
  5. package/src/automation/load-yaml.ts +20 -0
  6. package/src/automation/runner.ts +139 -0
  7. package/src/automation/types.ts +38 -0
  8. package/src/class-registry.ts +189 -0
  9. package/src/components/PassageLink.tsx +10 -0
  10. package/src/components/StoryInterface.tsx +2 -1
  11. package/src/components/macros/Back.tsx +10 -0
  12. package/src/components/macros/Button.tsx +13 -2
  13. package/src/components/macros/Checkbox.tsx +11 -0
  14. package/src/components/macros/Cycle.tsx +19 -1
  15. package/src/components/macros/Do.tsx +3 -2
  16. package/src/components/macros/Forward.tsx +10 -0
  17. package/src/components/macros/Listbox.tsx +15 -3
  18. package/src/components/macros/MacroLink.tsx +19 -8
  19. package/src/components/macros/Meter.tsx +130 -0
  20. package/src/components/macros/Numberbox.tsx +12 -3
  21. package/src/components/macros/QuickLoad.tsx +10 -0
  22. package/src/components/macros/QuickSave.tsx +9 -0
  23. package/src/components/macros/Radiobutton.tsx +11 -0
  24. package/src/components/macros/Restart.tsx +9 -0
  25. package/src/components/macros/Set.tsx +3 -2
  26. package/src/components/macros/Textarea.tsx +12 -3
  27. package/src/components/macros/Textbox.tsx +12 -3
  28. package/src/components/macros/Type.tsx +2 -9
  29. package/src/hooks/use-action.ts +49 -0
  30. package/src/index.tsx +11 -5
  31. package/src/markup/ast.ts +3 -6
  32. package/src/markup/markdown.ts +1 -4
  33. package/src/markup/render.tsx +13 -6
  34. package/src/markup/tokenizer.ts +66 -8
  35. package/src/saves/save-manager.ts +24 -4
  36. package/src/store.ts +17 -13
  37. package/src/story-api.ts +97 -0
  38. package/src/story-init.ts +3 -2
  39. package/src/story-variables.ts +3 -1
  40. package/src/styles.css +31 -1
  41. package/src/utils/extract-text.ts +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -40,6 +40,7 @@
40
40
  "test": "vitest run",
41
41
  "test:watch": "vitest",
42
42
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
43
+ "typecheck": "tsc --noEmit",
43
44
  "format": "prettier --write .",
44
45
  "format:check": "prettier --check .",
45
46
  "docs:dev": "vitepress dev docs",
@@ -57,11 +58,13 @@
57
58
  },
58
59
  "devDependencies": {
59
60
  "@preact/preset-vite": "^2.9.0",
61
+ "@rohal12/twee-ts": "^1.0.0",
62
+ "@types/js-yaml": "^4.0.9",
60
63
  "@vitest/coverage-v8": "^4.0.18",
61
64
  "happy-dom": "^20.7.0",
65
+ "js-yaml": "^4.1.1",
62
66
  "playwright": "^1.58.2",
63
67
  "prettier": "^3.8.1",
64
- "tweenode": "^0.3.0",
65
68
  "typescript": "^5.7.0",
66
69
  "vite": "^6.0.0",
67
70
  "vite-plugin-singlefile": "^2.0.0",
@@ -0,0 +1,83 @@
1
+ export type ActionType =
2
+ | 'link'
3
+ | 'button'
4
+ | 'cycle'
5
+ | 'textbox'
6
+ | 'numberbox'
7
+ | 'textarea'
8
+ | 'checkbox'
9
+ | 'radiobutton'
10
+ | 'listbox'
11
+ | 'back'
12
+ | 'forward'
13
+ | 'restart'
14
+ | 'save'
15
+ | 'load';
16
+
17
+ export interface StoryAction {
18
+ id: string;
19
+ type: ActionType;
20
+ label: string;
21
+ target?: string;
22
+ variable?: string;
23
+ options?: string[];
24
+ value?: unknown;
25
+ disabled?: boolean;
26
+ perform: (value?: unknown) => void;
27
+ }
28
+
29
+ const actions = new Map<string, StoryAction>();
30
+ const listeners = new Set<() => void>();
31
+ const idCounters = new Map<string, number>();
32
+
33
+ export function generateActionId(
34
+ type: ActionType,
35
+ key: string,
36
+ authorId?: string,
37
+ ): string {
38
+ if (authorId) return authorId;
39
+
40
+ const base = `${type}:${key}`;
41
+ const count = (idCounters.get(base) ?? 0) + 1;
42
+ idCounters.set(base, count);
43
+ return count === 1 ? base : `${base}:${count}`;
44
+ }
45
+
46
+ export function registerAction(action: StoryAction): () => void {
47
+ actions.set(action.id, action);
48
+ notify();
49
+ return () => {
50
+ actions.delete(action.id);
51
+ notify();
52
+ };
53
+ }
54
+
55
+ export function getActions(): StoryAction[] {
56
+ return Array.from(actions.values());
57
+ }
58
+
59
+ export function getAction(id: string): StoryAction | undefined {
60
+ return actions.get(id);
61
+ }
62
+
63
+ export function clearActions(): void {
64
+ actions.clear();
65
+ notify();
66
+ }
67
+
68
+ export function resetIdCounters(): void {
69
+ idCounters.clear();
70
+ }
71
+
72
+ export function onActionsChanged(fn: () => void): () => void {
73
+ listeners.add(fn);
74
+ return () => {
75
+ listeners.delete(fn);
76
+ };
77
+ }
78
+
79
+ function notify(): void {
80
+ for (const fn of listeners) {
81
+ fn();
82
+ }
83
+ }
@@ -0,0 +1,11 @@
1
+ export { runAutomation } from './runner';
2
+ export type { RunOptions } from './runner';
3
+ export { parseAutomationYaml } from './load-yaml';
4
+ export type {
5
+ AutomationScript,
6
+ AutomationStep,
7
+ AssertStep,
8
+ ActionMatcher,
9
+ RunResult,
10
+ StepError,
11
+ } from './types';
@@ -0,0 +1,20 @@
1
+ import yaml from 'js-yaml';
2
+ import type { AutomationScript } from './types';
3
+
4
+ export function parseAutomationYaml(yamlContent: string): AutomationScript {
5
+ const doc = yaml.load(yamlContent) as Record<string, unknown>;
6
+
7
+ if (!doc || typeof doc !== 'object') {
8
+ throw new Error('Invalid YAML: expected an object');
9
+ }
10
+
11
+ if (typeof doc.name !== 'string') {
12
+ throw new Error('Invalid automation script: missing "name" field');
13
+ }
14
+
15
+ if (!Array.isArray(doc.steps)) {
16
+ throw new Error('Invalid automation script: missing "steps" array');
17
+ }
18
+
19
+ return doc as unknown as AutomationScript;
20
+ }
@@ -0,0 +1,139 @@
1
+ import type {
2
+ AutomationScript,
3
+ AutomationStep,
4
+ RunResult,
5
+ StepError,
6
+ ActionMatcher,
7
+ } from './types';
8
+ import type { StoryAPI } from '../story-api';
9
+ import type { StoryAction } from '../action-registry';
10
+
11
+ export interface RunOptions {
12
+ onStep?: (stepIndex: number, step: AutomationStep) => void;
13
+ }
14
+
15
+ export async function runAutomation(
16
+ story: StoryAPI,
17
+ script: AutomationScript,
18
+ options: RunOptions = {},
19
+ ): Promise<RunResult> {
20
+ const errors: StepError[] = [];
21
+ let stepsRun = 0;
22
+
23
+ // Navigate to start passage if specified
24
+ if (script.start) {
25
+ story.goto(script.start);
26
+ await story.waitForActions();
27
+ }
28
+
29
+ for (let i = 0; i < script.steps.length; i++) {
30
+ const step = script.steps[i];
31
+ options.onStep?.(i, step);
32
+
33
+ try {
34
+ await executeStep(story, step, i, errors);
35
+ } catch (err) {
36
+ errors.push({
37
+ step: i,
38
+ message: err instanceof Error ? err.message : String(err),
39
+ });
40
+ break;
41
+ }
42
+
43
+ stepsRun = i + 1;
44
+ }
45
+
46
+ return {
47
+ success: errors.length === 0,
48
+ stepsRun,
49
+ errors,
50
+ };
51
+ }
52
+
53
+ async function executeStep(
54
+ story: StoryAPI,
55
+ step: AutomationStep,
56
+ index: number,
57
+ errors: StepError[],
58
+ ): Promise<void> {
59
+ if (step.set) {
60
+ story.set(step.set);
61
+ }
62
+
63
+ if (step.action !== undefined) {
64
+ const id = typeof step.action === 'string' ? step.action : step.action.id;
65
+ const value =
66
+ typeof step.action === 'string' ? undefined : step.action.value;
67
+ story.performAction(id, value);
68
+ await story.waitForActions();
69
+ }
70
+
71
+ if (step.wait !== undefined) {
72
+ await new Promise((resolve) => setTimeout(resolve, step.wait));
73
+ }
74
+
75
+ if (step.assert) {
76
+ const { assert } = step;
77
+
78
+ if (assert.passage !== undefined) {
79
+ if (story.passage !== assert.passage) {
80
+ errors.push({
81
+ step: index,
82
+ message: `Expected passage "${assert.passage}", got "${story.passage}"`,
83
+ });
84
+ }
85
+ }
86
+
87
+ if (assert.variables) {
88
+ for (const [key, expected] of Object.entries(assert.variables)) {
89
+ const actual = story.get(key);
90
+ if (actual !== expected) {
91
+ errors.push({
92
+ step: index,
93
+ message: `Variable "${key}": expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
94
+ });
95
+ }
96
+ }
97
+ }
98
+
99
+ if (assert.actionCount !== undefined) {
100
+ const actions = story.getActions();
101
+ if (actions.length !== assert.actionCount) {
102
+ errors.push({
103
+ step: index,
104
+ message: `Expected ${assert.actionCount} actions, got ${actions.length}`,
105
+ });
106
+ }
107
+ }
108
+
109
+ if (assert.actions) {
110
+ const actions = story.getActions();
111
+ for (const matcher of assert.actions) {
112
+ if (!findMatchingAction(actions, matcher)) {
113
+ errors.push({
114
+ step: index,
115
+ message: `No action matching ${JSON.stringify(matcher)}`,
116
+ });
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ function findMatchingAction(
124
+ actions: StoryAction[],
125
+ matcher: ActionMatcher,
126
+ ): StoryAction | undefined {
127
+ return actions.find((action) => {
128
+ if (matcher.id !== undefined && action.id !== matcher.id) return false;
129
+ if (matcher.type !== undefined && action.type !== matcher.type)
130
+ return false;
131
+ if (matcher.target !== undefined && action.target !== matcher.target)
132
+ return false;
133
+ if (matcher.variable !== undefined && action.variable !== matcher.variable)
134
+ return false;
135
+ if (matcher.label !== undefined && action.label !== matcher.label)
136
+ return false;
137
+ return true;
138
+ });
139
+ }
@@ -0,0 +1,38 @@
1
+ export interface ActionMatcher {
2
+ type?: string;
3
+ id?: string;
4
+ target?: string;
5
+ variable?: string;
6
+ label?: string;
7
+ }
8
+
9
+ export interface AssertStep {
10
+ passage?: string;
11
+ variables?: Record<string, unknown>;
12
+ actions?: ActionMatcher[];
13
+ actionCount?: number;
14
+ }
15
+
16
+ export interface AutomationStep {
17
+ action?: string | { id: string; value?: unknown };
18
+ assert?: AssertStep;
19
+ wait?: number;
20
+ set?: Record<string, unknown>;
21
+ }
22
+
23
+ export interface AutomationScript {
24
+ name: string;
25
+ start?: string;
26
+ steps: AutomationStep[];
27
+ }
28
+
29
+ export interface StepError {
30
+ step: number;
31
+ message: string;
32
+ }
33
+
34
+ export interface RunResult {
35
+ success: boolean;
36
+ stepsRun: number;
37
+ errors: StepError[];
38
+ }
@@ -0,0 +1,189 @@
1
+ // Class registry for preserving class instances across clone/save/load cycles.
2
+
3
+ type Constructor = new (...args: any[]) => any;
4
+
5
+ const registry = new Map<string, Constructor>();
6
+ const ctorToName = new Map<Constructor, string>();
7
+
8
+ export function registerClass(name: string, ctor: Constructor): void {
9
+ registry.set(name, ctor);
10
+ ctorToName.set(ctor, name);
11
+ }
12
+
13
+ export function getClassName(ctor: Constructor): string | undefined {
14
+ return ctorToName.get(ctor);
15
+ }
16
+
17
+ export function clearRegistry(): void {
18
+ registry.clear();
19
+ ctorToName.clear();
20
+ }
21
+
22
+ // --- Deep Clone ---
23
+
24
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
25
+ if (typeof value !== 'object' || value === null) return false;
26
+ const proto = Object.getPrototypeOf(value);
27
+ return proto === Object.prototype || proto === null;
28
+ }
29
+
30
+ export function deepClone<T>(value: T): T {
31
+ const seen = new Map<object, object>();
32
+
33
+ function clone(val: unknown): unknown {
34
+ if (val === null || typeof val !== 'object') return val;
35
+
36
+ const obj = val as object;
37
+ if (seen.has(obj)) return seen.get(obj);
38
+
39
+ if (val instanceof Date) return new Date(val.getTime()) as unknown;
40
+ if (val instanceof RegExp)
41
+ return new RegExp(val.source, val.flags) as unknown;
42
+
43
+ if (Array.isArray(val)) {
44
+ const arr: unknown[] = [];
45
+ seen.set(obj, arr);
46
+ for (let i = 0; i < val.length; i++) {
47
+ arr[i] = clone(val[i]);
48
+ }
49
+ return arr;
50
+ }
51
+
52
+ // Registered class instance
53
+ const ctor = obj.constructor as Constructor;
54
+ const name = ctorToName.get(ctor);
55
+ if (name !== undefined) {
56
+ const copy = Object.create(Object.getPrototypeOf(obj)) as Record<
57
+ string,
58
+ unknown
59
+ >;
60
+ seen.set(obj, copy);
61
+ for (const key of Object.keys(obj)) {
62
+ copy[key] = clone((obj as Record<string, unknown>)[key]);
63
+ }
64
+ return copy;
65
+ }
66
+
67
+ // Plain object (or unregistered class — treat as plain)
68
+ if (isPlainObject(val) || typeof val === 'object') {
69
+ const copy: Record<string, unknown> = {};
70
+ seen.set(obj, copy);
71
+ for (const key of Object.keys(obj)) {
72
+ copy[key] = clone((obj as Record<string, unknown>)[key]);
73
+ }
74
+ return copy;
75
+ }
76
+
77
+ return val;
78
+ }
79
+
80
+ return clone(value) as T;
81
+ }
82
+
83
+ // --- Serialize ---
84
+
85
+ const CLASS_TAG = '__spindle_class__';
86
+ const DATA_TAG = '__spindle_data__';
87
+
88
+ export function serialize<T>(value: T): T {
89
+ const seen = new Set<object>();
90
+
91
+ function ser(val: unknown): unknown {
92
+ if (val === null || typeof val !== 'object') return val;
93
+
94
+ const obj = val as object;
95
+ if (seen.has(obj)) {
96
+ throw new Error('spindle: Cannot serialize circular references');
97
+ }
98
+ seen.add(obj);
99
+
100
+ if (val instanceof Date) {
101
+ seen.delete(obj);
102
+ return val.toISOString();
103
+ }
104
+
105
+ if (val instanceof RegExp) {
106
+ seen.delete(obj);
107
+ return val.toString();
108
+ }
109
+
110
+ if (Array.isArray(val)) {
111
+ const result = val.map((item) => ser(item));
112
+ seen.delete(obj);
113
+ return result;
114
+ }
115
+
116
+ // Registered class instance
117
+ const ctor = obj.constructor as Constructor;
118
+ const name = ctorToName.get(ctor);
119
+ if (name !== undefined) {
120
+ const data: Record<string, unknown> = {};
121
+ for (const key of Object.keys(obj)) {
122
+ data[key] = ser((obj as Record<string, unknown>)[key]);
123
+ }
124
+ seen.delete(obj);
125
+ return { [CLASS_TAG]: name, [DATA_TAG]: data };
126
+ }
127
+
128
+ // Plain object
129
+ const result: Record<string, unknown> = {};
130
+ for (const key of Object.keys(obj)) {
131
+ result[key] = ser((obj as Record<string, unknown>)[key]);
132
+ }
133
+ seen.delete(obj);
134
+ return result;
135
+ }
136
+
137
+ return ser(value) as T;
138
+ }
139
+
140
+ // --- Deserialize ---
141
+
142
+ export function deserialize<T>(value: T): T {
143
+ function deser(val: unknown): unknown {
144
+ if (val === null || typeof val !== 'object') return val;
145
+
146
+ if (Array.isArray(val)) {
147
+ return val.map((item) => deser(item));
148
+ }
149
+
150
+ const obj = val as Record<string, unknown>;
151
+
152
+ // Tagged class instance (from serialized data)
153
+ if (CLASS_TAG in obj && DATA_TAG in obj) {
154
+ const name = obj[CLASS_TAG] as string;
155
+ const data = obj[DATA_TAG] as Record<string, unknown>;
156
+ const ctor = registry.get(name);
157
+ if (!ctor) {
158
+ console.warn(
159
+ `spindle: Class "${name}" not registered. Falling back to plain object.`,
160
+ );
161
+ const plain: Record<string, unknown> = {};
162
+ for (const key of Object.keys(data)) {
163
+ plain[key] = deser(data[key]);
164
+ }
165
+ return plain;
166
+ }
167
+ const instance = Object.create(ctor.prototype) as Record<string, unknown>;
168
+ for (const key of Object.keys(data)) {
169
+ instance[key] = deser(data[key]);
170
+ }
171
+ return instance;
172
+ }
173
+
174
+ // Already-live registered class instance — pass through as-is
175
+ const ctor = (obj as object).constructor as Constructor;
176
+ if (ctorToName.has(ctor)) {
177
+ return val;
178
+ }
179
+
180
+ // Plain object
181
+ const result: Record<string, unknown> = {};
182
+ for (const key of Object.keys(obj)) {
183
+ result[key] = deser(obj[key]);
184
+ }
185
+ return result;
186
+ }
187
+
188
+ return deser(value) as T;
189
+ }
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore } from '../store';
2
+ import { useAction } from '../hooks/use-action';
2
3
 
3
4
  interface PassageLinkProps {
4
5
  target: string;
@@ -20,6 +21,15 @@ export function PassageLink({
20
21
  navigate(target);
21
22
  };
22
23
 
24
+ useAction({
25
+ type: 'link',
26
+ key: target,
27
+ authorId: id,
28
+ label: typeof children === 'string' ? children : target,
29
+ target,
30
+ perform: () => navigate(target),
31
+ });
32
+
23
33
  const cls = className ? `passage-link ${className}` : 'passage-link';
24
34
 
25
35
  return (
@@ -4,7 +4,8 @@ import { tokenize } from '../markup/tokenizer';
4
4
  import { buildAST } from '../markup/ast';
5
5
  import { renderNodes } from '../markup/render';
6
6
 
7
- const DEFAULT_MARKUP = '{story-title}{back}{forward}{restart}{quicksave}{quickload}{saves}{settings}';
7
+ const DEFAULT_MARKUP =
8
+ '{story-title}{back}{forward}{restart}{quicksave}{quickload}{saves}{settings}';
8
9
 
9
10
  export function StoryInterface() {
10
11
  const storyData = useStoryStore((s) => s.storyData);
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore } from '../../store';
2
+ import { useAction } from '../../hooks/use-action';
2
3
 
3
4
  interface BackProps {
4
5
  className?: string;
@@ -10,6 +11,15 @@ export function Back({ className, id }: BackProps) {
10
11
  const canGoBack = useStoryStore((s) => s.historyIndex > 0);
11
12
  const cls = className ? `menubar-button ${className}` : 'menubar-button';
12
13
 
14
+ useAction({
15
+ type: 'back',
16
+ key: 'back',
17
+ authorId: id,
18
+ label: 'Back',
19
+ disabled: !canGoBack,
20
+ perform: () => goBack(),
21
+ });
22
+
13
23
  return (
14
24
  <button
15
25
  id={id}
@@ -1,6 +1,9 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { execute } from '../../expression';
3
3
  import { renderInlineNodes } from '../../markup/render';
4
+ import { deepClone } from '../../class-registry';
5
+ import { collectText } from '../../utils/extract-text';
6
+ import { useAction } from '../../hooks/use-action';
4
7
  import type { ASTNode } from '../../markup/ast';
5
8
 
6
9
  interface ButtonProps {
@@ -13,8 +16,8 @@ interface ButtonProps {
13
16
  export function Button({ rawArgs, children, className, id }: ButtonProps) {
14
17
  const handleClick = () => {
15
18
  const state = useStoryStore.getState();
16
- const vars = structuredClone(state.variables);
17
- const temps = structuredClone(state.temporary);
19
+ const vars = deepClone(state.variables);
20
+ const temps = deepClone(state.temporary);
18
21
 
19
22
  try {
20
23
  execute(rawArgs, vars, temps);
@@ -35,6 +38,14 @@ export function Button({ rawArgs, children, className, id }: ButtonProps) {
35
38
  }
36
39
  };
37
40
 
41
+ useAction({
42
+ type: 'button',
43
+ key: rawArgs,
44
+ authorId: id,
45
+ label: collectText(children) || rawArgs,
46
+ perform: handleClick,
47
+ });
48
+
38
49
  const cls = className ? `macro-button ${className}` : 'macro-button';
39
50
 
40
51
  return (
@@ -1,4 +1,5 @@
1
1
  import { useStoryStore } from '../../store';
2
+ import { useAction } from '../../hooks/use-action';
2
3
 
3
4
  interface CheckboxProps {
4
5
  rawArgs: string;
@@ -23,6 +24,16 @@ export function Checkbox({ rawArgs, className, id }: CheckboxProps) {
23
24
  const value = useStoryStore((s) => s.variables[name]);
24
25
  const setVariable = useStoryStore((s) => s.setVariable);
25
26
 
27
+ useAction({
28
+ type: 'checkbox',
29
+ key: `$${name}`,
30
+ authorId: id,
31
+ label: label || name,
32
+ variable: name,
33
+ value: !!value,
34
+ perform: (v) => setVariable(name, v !== undefined ? !!v : !value),
35
+ });
36
+
26
37
  const cls = className ? `macro-checkbox ${className}` : 'macro-checkbox';
27
38
 
28
39
  return (
@@ -1,5 +1,6 @@
1
1
  import { useStoryStore } from '../../store';
2
2
  import { extractOptions } from './option-utils';
3
+ import { useAction } from '../../hooks/use-action';
3
4
  import type { ASTNode } from '../../markup/ast';
4
5
 
5
6
  interface CycleProps {
@@ -25,6 +26,23 @@ export function Cycle({ rawArgs, children, className, id }: CycleProps) {
25
26
  setVariable(name, options[nextIndex]);
26
27
  };
27
28
 
29
+ useAction({
30
+ type: 'cycle',
31
+ key: `$${name}`,
32
+ authorId: id,
33
+ label: value == null ? options[0] || '' : String(value),
34
+ variable: name,
35
+ options,
36
+ value,
37
+ perform: (v) => {
38
+ if (v !== undefined) {
39
+ setVariable(name, v);
40
+ } else {
41
+ handleClick();
42
+ }
43
+ },
44
+ });
45
+
28
46
  const cls = className ? `macro-cycle ${className}` : 'macro-cycle';
29
47
 
30
48
  return (
@@ -33,7 +51,7 @@ export function Cycle({ rawArgs, children, className, id }: CycleProps) {
33
51
  class={cls}
34
52
  onClick={handleClick}
35
53
  >
36
- {value == null ? (options[0] || '') : String(value)}
54
+ {value == null ? options[0] || '' : String(value)}
37
55
  </button>
38
56
  );
39
57
  }
@@ -2,6 +2,7 @@ import { useLayoutEffect } from 'preact/hooks';
2
2
  import { useStoryStore } from '../../store';
3
3
  import { execute } from '../../expression';
4
4
  import type { ASTNode } from '../../markup/ast';
5
+ import { deepClone } from '../../class-registry';
5
6
 
6
7
  interface DoProps {
7
8
  children: ASTNode[];
@@ -19,8 +20,8 @@ export function Do({ children }: DoProps) {
19
20
 
20
21
  useLayoutEffect(() => {
21
22
  const state = useStoryStore.getState();
22
- const vars = { ...state.variables };
23
- const temps = { ...state.temporary };
23
+ const vars = deepClone(state.variables);
24
+ const temps = deepClone(state.temporary);
24
25
 
25
26
  try {
26
27
  execute(code, vars, temps);