@rohal12/spindle 0.37.1 → 0.38.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.37.1",
3
+ "version": "0.38.1",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -0,0 +1,39 @@
1
+ import { useContext } from 'preact/hooks';
2
+ import { useStoryStore } from '../../store';
3
+ import { evaluate } from '../../expression';
4
+ import { LocalsValuesContext } from '../../markup/render';
5
+ import { useInterpolate } from '../../hooks/use-interpolate';
6
+
7
+ interface ExprDisplayProps {
8
+ expression: string;
9
+ className?: string;
10
+ id?: string;
11
+ }
12
+
13
+ export function ExprDisplay({ expression, className, id }: ExprDisplayProps) {
14
+ const resolve = useInterpolate();
15
+ className = resolve(className);
16
+ id = resolve(id);
17
+ const localsValues = useContext(LocalsValuesContext);
18
+ const variables = useStoryStore((s) => s.variables);
19
+ const temporary = useStoryStore((s) => s.temporary);
20
+
21
+ let display: string;
22
+ try {
23
+ const value = evaluate(expression, variables, temporary, localsValues);
24
+ display = value == null ? '' : String(value);
25
+ } catch {
26
+ display = `{error: ${expression}}`;
27
+ }
28
+
29
+ if (className || id)
30
+ return (
31
+ <span
32
+ id={id}
33
+ class={className}
34
+ >
35
+ {display}
36
+ </span>
37
+ );
38
+ return <>{display}</>;
39
+ }
@@ -71,6 +71,7 @@ defineMacro({
71
71
  render(_props, ctx) {
72
72
  const currentPassage = useStoryStore((s) => s.currentPassage);
73
73
  const storyData = useStoryStore((s) => s.storyData);
74
+ const renderDeferred = useStoryStore((s) => s.renderDeferred);
74
75
 
75
76
  // Render-gating: displayedPassage controls which passage is visually shown.
76
77
  // The store updates immediately, but the visual swap is deferred by the
@@ -245,12 +246,14 @@ defineMacro({
245
246
  // eslint-disable-next-line react-hooks/exhaustive-deps
246
247
  }, [currentPassage]);
247
248
 
248
- // Resolve the passage to display (or show nothing during outgoing phase)
249
- const passage = displayedPassage
250
- ? storyData?.passages.get(displayedPassage)
251
- : null;
249
+ // When render is deferred, show StoryLoading passage or nothing
250
+ const effectivePassage = renderDeferred
251
+ ? (storyData?.passages.get('StoryLoading') ?? null)
252
+ : displayedPassage
253
+ ? storyData?.passages.get(displayedPassage)
254
+ : null;
252
255
 
253
- if (!passage && displayedPassage) {
256
+ if (!effectivePassage && displayedPassage && !renderDeferred) {
254
257
  return (
255
258
  <div class="error">
256
259
  Error: Passage &ldquo;{displayedPassage}&rdquo; not found.
@@ -265,7 +268,7 @@ defineMacro({
265
268
  id={ctx.id ?? 'story'}
266
269
  class={ctx.className ?? 'story'}
267
270
  >
268
- {readyPassage && (
271
+ {!renderDeferred && readyPassage && (
269
272
  <div
270
273
  key={`ready-${currentPassage}`}
271
274
  hidden
@@ -277,11 +280,11 @@ defineMacro({
277
280
  class="passage-container"
278
281
  ref={containerRef}
279
282
  >
280
- {passage && (
283
+ {effectivePassage && (
281
284
  <Passage
282
- passage={passage}
283
- key={displayedPassage}
284
- dataTransition={resolvedTypeRef.current}
285
+ passage={effectivePassage}
286
+ key={renderDeferred ? 'loading' : displayedPassage}
287
+ dataTransition={renderDeferred ? 'none' : resolvedTypeRef.current}
285
288
  />
286
289
  )}
287
290
  </div>
package/src/index.tsx CHANGED
@@ -2,7 +2,7 @@ import { render } from 'preact';
2
2
  import { App } from './components/App';
3
3
  import { parseStoryData } from './parser';
4
4
  import { useStoryStore, fireStoryInit } from './store';
5
- import { installStoryAPI } from './story-api';
5
+ import { installStoryAPI, getReadyPromise } from './story-api';
6
6
  import { resetIdCounters } from './action-registry';
7
7
  import { executeStoryInit } from './story-init';
8
8
  import { checkTriggers, reinitTriggerState } from './triggers';
@@ -200,7 +200,14 @@ function boot() {
200
200
 
201
201
  render(<App />, root);
202
202
 
203
- document.dispatchEvent(new CustomEvent(':storyready'));
203
+ const pending = getReadyPromise();
204
+ if (pending) {
205
+ pending.then(() => {
206
+ document.dispatchEvent(new CustomEvent(':storyready'));
207
+ });
208
+ } else {
209
+ document.dispatchEvent(new CustomEvent(':storyready'));
210
+ }
204
211
  }
205
212
 
206
213
  if (document.readyState === 'loading') {
@@ -1,5 +1,7 @@
1
- const INTERP_RE = /\{(\$[\w.]+|_[\w.]+|@[\w.]+)\}/g;
2
- const INTERP_TEST = /\{[\$_@][\w.]+\}/;
1
+ import { evaluate } from './expression';
2
+
3
+ /** Detects any {…} block that starts with a sigil ($, _, @). */
4
+ const INTERP_TEST = /\{[\$_@]\w/;
3
5
 
4
6
  export function hasInterpolation(s: string): boolean {
5
7
  return INTERP_TEST.test(s);
@@ -14,31 +16,107 @@ function resolveDotPath(root: unknown, parts: string[]): unknown {
14
16
  return value;
15
17
  }
16
18
 
19
+ /**
20
+ * Evaluate an expression string and return its stringified result.
21
+ * Uses the full expression evaluator from expression.ts.
22
+ */
23
+ export function interpolateExpression(
24
+ expr: string,
25
+ variables: Record<string, unknown>,
26
+ temporary: Record<string, unknown>,
27
+ locals: Record<string, unknown>,
28
+ ): string {
29
+ const value = evaluate(expr, variables, temporary, locals);
30
+ return value == null ? '' : String(value);
31
+ }
32
+
33
+ function resolveSimple(
34
+ ref: string,
35
+ variables: Record<string, unknown>,
36
+ temporary: Record<string, unknown>,
37
+ locals: Record<string, unknown>,
38
+ ): string {
39
+ const prefix = ref[0]!;
40
+ const path = ref.slice(1);
41
+ const parts = path.split('.');
42
+ const root = parts[0]!;
43
+
44
+ let value: unknown;
45
+ if (prefix === '$') {
46
+ value = variables[root];
47
+ } else if (prefix === '_') {
48
+ value = temporary[root];
49
+ } else {
50
+ value = locals[root];
51
+ }
52
+
53
+ if (parts.length > 1) {
54
+ value = resolveDotPath(value, parts);
55
+ }
56
+
57
+ return value == null ? '' : String(value);
58
+ }
59
+
17
60
  export function interpolate(
18
61
  template: string,
19
62
  variables: Record<string, unknown>,
20
63
  temporary: Record<string, unknown>,
21
64
  locals: Record<string, unknown>,
22
65
  ): string {
23
- return template.replace(INTERP_RE, (_match, ref: string) => {
24
- const prefix = ref[0]!;
25
- const path = ref.slice(1);
26
- const parts = path.split('.');
27
- const root = parts[0]!;
28
-
29
- let value: unknown;
30
- if (prefix === '$') {
31
- value = variables[root];
32
- } else if (prefix === '_') {
33
- value = temporary[root];
34
- } else {
35
- value = locals[root];
66
+ // Manual scan: process {…} blocks containing sigils.
67
+ // Simple dot-path refs use the fast resolver; everything else falls back
68
+ // to the full expression evaluator.
69
+ let result = '';
70
+ let i = 0;
71
+
72
+ while (i < template.length) {
73
+ if (template[i] !== '{') {
74
+ // Accumulate plain text
75
+ const nextBrace = template.indexOf('{', i);
76
+ if (nextBrace === -1) {
77
+ result += template.slice(i);
78
+ break;
79
+ }
80
+ result += template.slice(i, nextBrace);
81
+ i = nextBrace;
82
+ continue;
83
+ }
84
+
85
+ // Found { — check if it starts a sigil expression
86
+ i++; // skip {
87
+
88
+ const sigil = template[i];
89
+ if (sigil !== '$' && sigil !== '_' && sigil !== '@') {
90
+ // Not an interpolation — emit the { as text
91
+ result += '{';
92
+ continue;
36
93
  }
37
94
 
38
- if (parts.length > 1) {
39
- value = resolveDotPath(value, parts);
95
+ // Scan for balanced closing }
96
+ let depth = 1;
97
+ let j = i;
98
+ while (j < template.length && depth > 0) {
99
+ j++;
100
+ if (template[j] === '{') depth++;
101
+ else if (template[j] === '}') depth--;
40
102
  }
41
103
 
42
- return value == null ? '' : String(value);
43
- });
104
+ if (depth !== 0) {
105
+ // Unbalanced — emit as text
106
+ result += '{';
107
+ continue;
108
+ }
109
+
110
+ const inner = template.slice(i, j);
111
+ i = j + 1; // skip past closing }
112
+
113
+ // Try simple dot-path match first
114
+ if (/^[\$_@][\w.]+$/.test(inner)) {
115
+ result += resolveSimple(inner, variables, temporary, locals);
116
+ } else {
117
+ result += interpolateExpression(inner, variables, temporary, locals);
118
+ }
119
+ }
120
+
121
+ return result;
44
122
  }
package/src/markup/ast.ts CHANGED
@@ -13,6 +13,13 @@ export interface VariableNode {
13
13
  id?: string;
14
14
  }
15
15
 
16
+ export interface ExpressionNode {
17
+ type: 'expression';
18
+ expression: string;
19
+ className?: string;
20
+ id?: string;
21
+ }
22
+
16
23
  export interface Branch {
17
24
  rawArgs: string;
18
25
  className?: string;
@@ -37,7 +44,12 @@ export interface HtmlNode {
37
44
  children: ASTNode[];
38
45
  }
39
46
 
40
- export type ASTNode = TextNode | VariableNode | MacroNode | HtmlNode;
47
+ export type ASTNode =
48
+ | TextNode
49
+ | VariableNode
50
+ | ExpressionNode
51
+ | MacroNode
52
+ | HtmlNode;
41
53
 
42
54
  /** Macros that require a closing tag and can contain children */
43
55
  const BLOCK_MACROS = new Set([
@@ -131,6 +143,17 @@ export function buildAST(tokens: Token[]): ASTNode[] {
131
143
  break;
132
144
  }
133
145
 
146
+ case 'expression': {
147
+ const exprNode: ExpressionNode = {
148
+ type: 'expression',
149
+ expression: token.expression,
150
+ };
151
+ if (token.className) exprNode.className = token.className;
152
+ if (token.id) exprNode.id = token.id;
153
+ current().push(exprNode);
154
+ break;
155
+ }
156
+
134
157
  case 'html': {
135
158
  if (token.isSelfClose) {
136
159
  // Self-closing HTML tag (br, hr, img, etc.)
@@ -1,6 +1,7 @@
1
1
  import { createContext } from 'preact';
2
2
  import { useContext } from 'preact/hooks';
3
3
  import { VarDisplay } from '../components/macros/VarDisplay';
4
+ import { ExprDisplay } from '../components/macros/ExprDisplay';
4
5
  import { WidgetInvocation } from '../components/macros/WidgetInvocation';
5
6
  import { getWidget } from '../widgets/widget-registry';
6
7
  import { getMacro, isSubMacro } from '../registry';
@@ -181,6 +182,16 @@ function renderSingleNode(
181
182
  />
182
183
  );
183
184
 
185
+ case 'expression':
186
+ return (
187
+ <ExprDisplay
188
+ key={key}
189
+ expression={node.expression}
190
+ className={node.className}
191
+ id={node.id}
192
+ />
193
+ );
194
+
184
195
  case 'macro':
185
196
  return renderMacro(node, key);
186
197
 
@@ -36,6 +36,15 @@ export interface VariableToken {
36
36
  end: number;
37
37
  }
38
38
 
39
+ export interface ExpressionToken {
40
+ type: 'expression';
41
+ expression: string;
42
+ className?: string;
43
+ id?: string;
44
+ start: number;
45
+ end: number;
46
+ }
47
+
39
48
  export interface HtmlToken {
40
49
  type: 'html';
41
50
  tag: string;
@@ -51,6 +60,7 @@ export type Token =
51
60
  | LinkToken
52
61
  | MacroToken
53
62
  | VariableToken
63
+ | ExpressionToken
54
64
  | HtmlToken;
55
65
 
56
66
  /** Tag name must start with a letter (covers standard and custom elements). */
@@ -233,6 +243,20 @@ function parseHtmlAttributes(
233
243
  return { attributes, endIdx: j };
234
244
  }
235
245
 
246
+ /**
247
+ * Scan for the balanced closing } starting at position i (just past the {).
248
+ * Returns the index of the closing } or -1 if unbalanced.
249
+ */
250
+ function scanBalancedBrace(input: string, i: number): number {
251
+ let depth = 1;
252
+ while (i < input.length && depth > 0) {
253
+ if (input[i] === '{') depth++;
254
+ else if (input[i] === '}') depth--;
255
+ if (depth > 0) i++;
256
+ }
257
+ return depth === 0 ? i : -1;
258
+ }
259
+
236
260
  /**
237
261
  * Single-pass tokenizer for Twine passage content.
238
262
  * Recognizes: [[links]], {$variable}, {_temporary}, {macroName args}
@@ -341,7 +365,7 @@ export function tokenize(input: string): Token[] {
341
365
  const charAfter = input[afterSelectors];
342
366
 
343
367
  if (charAfter === '$') {
344
- // {.class#id $variable.field}
368
+ // {.class#id $variable.field} or {.class $expr[...]}
345
369
  i = afterSelectors + 1;
346
370
  const nameStart = i;
347
371
  while (i < input.length && /[\w.]/.test(input[i]!)) i++;
@@ -362,14 +386,31 @@ export function tokenize(input: string): Token[] {
362
386
  textStart = i;
363
387
  continue;
364
388
  }
365
- // Not valid — treat as text
389
+ // Complex expression — scan for balanced closing }
390
+ const closeIdx$ = scanBalancedBrace(input, nameStart);
391
+ if (closeIdx$ !== -1) {
392
+ const expression = input.slice(afterSelectors, closeIdx$);
393
+ i = closeIdx$ + 1;
394
+ const token: ExpressionToken = {
395
+ type: 'expression',
396
+ expression,
397
+ start,
398
+ end: i,
399
+ };
400
+ if (className) token.className = className;
401
+ if (id) token.id = id;
402
+ tokens.push(token);
403
+ textStart = i;
404
+ continue;
405
+ }
406
+ // Unbalanced — treat as text
366
407
  i = start + 1;
367
408
  textStart = start;
368
409
  continue;
369
410
  }
370
411
 
371
412
  if (charAfter === '_') {
372
- // {.class#id _temporary.field}
413
+ // {.class#id _temporary.field} or {.class _expr[...]}
373
414
  i = afterSelectors + 1;
374
415
  const nameStart = i;
375
416
  while (i < input.length && /[\w.]/.test(input[i]!)) i++;
@@ -390,14 +431,31 @@ export function tokenize(input: string): Token[] {
390
431
  textStart = i;
391
432
  continue;
392
433
  }
393
- // Not valid — treat as text
434
+ // Complex expression — scan for balanced closing }
435
+ const closeIdx_ = scanBalancedBrace(input, nameStart);
436
+ if (closeIdx_ !== -1) {
437
+ const expression = input.slice(afterSelectors, closeIdx_);
438
+ i = closeIdx_ + 1;
439
+ const token: ExpressionToken = {
440
+ type: 'expression',
441
+ expression,
442
+ start,
443
+ end: i,
444
+ };
445
+ if (className) token.className = className;
446
+ if (id) token.id = id;
447
+ tokens.push(token);
448
+ textStart = i;
449
+ continue;
450
+ }
451
+ // Unbalanced — treat as text
394
452
  i = start + 1;
395
453
  textStart = start;
396
454
  continue;
397
455
  }
398
456
 
399
457
  if (charAfter === '@') {
400
- // {.class#id @local.field}
458
+ // {.class#id @local.field} or {.class @expr[...]}
401
459
  i = afterSelectors + 1;
402
460
  const nameStart = i;
403
461
  while (i < input.length && /[\w.]/.test(input[i]!)) i++;
@@ -418,7 +476,24 @@ export function tokenize(input: string): Token[] {
418
476
  textStart = i;
419
477
  continue;
420
478
  }
421
- // Not valid — treat as text
479
+ // Complex expression — scan for balanced closing }
480
+ const closeIdx_at = scanBalancedBrace(input, nameStart);
481
+ if (closeIdx_at !== -1) {
482
+ const expression = input.slice(afterSelectors, closeIdx_at);
483
+ i = closeIdx_at + 1;
484
+ const token: ExpressionToken = {
485
+ type: 'expression',
486
+ expression,
487
+ start,
488
+ end: i,
489
+ };
490
+ if (className) token.className = className;
491
+ if (id) token.id = id;
492
+ tokens.push(token);
493
+ textStart = i;
494
+ continue;
495
+ }
496
+ // Unbalanced — treat as text
422
497
  i = start + 1;
423
498
  textStart = start;
424
499
  continue;
@@ -468,7 +543,7 @@ export function tokenize(input: string): Token[] {
468
543
  continue;
469
544
  }
470
545
 
471
- // {$variable} or {$variable.field.subfield}
546
+ // {$variable} or {$variable.field.subfield} or {$expr[...]}
472
547
  if (nextChar === '$') {
473
548
  flushText(i);
474
549
  i += 2;
@@ -488,13 +563,27 @@ export function tokenize(input: string): Token[] {
488
563
  textStart = i;
489
564
  continue;
490
565
  }
491
- // Not a valid variable token — treat as text
566
+ // Complex expression — scan for balanced closing }
567
+ const closeIdx = scanBalancedBrace(input, nameStart);
568
+ if (closeIdx !== -1) {
569
+ const expression = input.slice(start + 1, closeIdx);
570
+ i = closeIdx + 1;
571
+ tokens.push({
572
+ type: 'expression',
573
+ expression,
574
+ start,
575
+ end: i,
576
+ });
577
+ textStart = i;
578
+ continue;
579
+ }
580
+ // Unbalanced — treat as text
492
581
  i = start + 1;
493
582
  textStart = start;
494
583
  continue;
495
584
  }
496
585
 
497
- // {_temporary.field}
586
+ // {_temporary.field} or {_expr[...]}
498
587
  if (nextChar === '_') {
499
588
  flushText(i);
500
589
  i += 2;
@@ -514,13 +603,27 @@ export function tokenize(input: string): Token[] {
514
603
  textStart = i;
515
604
  continue;
516
605
  }
517
- // Not a valid temporary token — treat as text
606
+ // Complex expression — scan for balanced closing }
607
+ const closeIdx = scanBalancedBrace(input, nameStart);
608
+ if (closeIdx !== -1) {
609
+ const expression = input.slice(start + 1, closeIdx);
610
+ i = closeIdx + 1;
611
+ tokens.push({
612
+ type: 'expression',
613
+ expression,
614
+ start,
615
+ end: i,
616
+ });
617
+ textStart = i;
618
+ continue;
619
+ }
620
+ // Unbalanced — treat as text
518
621
  i = start + 1;
519
622
  textStart = start;
520
623
  continue;
521
624
  }
522
625
 
523
- // {@local.field}
626
+ // {@local.field} or {@expr[...]}
524
627
  if (nextChar === '@') {
525
628
  flushText(i);
526
629
  i += 2;
@@ -540,7 +643,21 @@ export function tokenize(input: string): Token[] {
540
643
  textStart = i;
541
644
  continue;
542
645
  }
543
- // Not a valid local token — treat as text
646
+ // Complex expression — scan for balanced closing }
647
+ const closeIdx = scanBalancedBrace(input, nameStart);
648
+ if (closeIdx !== -1) {
649
+ const expression = input.slice(start + 1, closeIdx);
650
+ i = closeIdx + 1;
651
+ tokens.push({
652
+ type: 'expression',
653
+ expression,
654
+ start,
655
+ end: i,
656
+ });
657
+ textStart = i;
658
+ continue;
659
+ }
660
+ // Unbalanced — treat as text
544
661
  i = start + 1;
545
662
  textStart = start;
546
663
  continue;
package/src/store.ts CHANGED
@@ -41,6 +41,7 @@ const SPECIAL_PASSAGES = new Set([
41
41
  'StoryInit',
42
42
  'StoryInterface',
43
43
  'StoryVariables',
44
+ 'StoryLoading',
44
45
  'SaveTitle',
45
46
  'PassageReady',
46
47
  'PassageHeader',
@@ -233,6 +234,7 @@ export interface StoryState {
233
234
  transitionConfig: TransitionConfig | null;
234
235
  nextTransition: TransitionConfig | null;
235
236
  nobr: boolean;
237
+ renderDeferred: boolean;
236
238
 
237
239
  setMaxHistory: (limit: number) => void;
238
240
  init: (
@@ -263,6 +265,8 @@ export interface StoryState {
263
265
  setTransition: (config: TransitionConfig | null) => void;
264
266
  setNextTransition: (config: TransitionConfig | null) => void;
265
267
  consumeNextTransition: () => TransitionConfig | null;
268
+ deferRender: () => void;
269
+ clearDeferredRender: () => void;
266
270
  }
267
271
 
268
272
  export const useStoryStore = create<StoryState>()(
@@ -284,6 +288,7 @@ export const useStoryStore = create<StoryState>()(
284
288
  transitionConfig: null,
285
289
  nextTransition: null,
286
290
  nobr: false,
291
+ renderDeferred: false,
287
292
 
288
293
  setMaxHistory: (limit: number) => {
289
294
  set((state) => {
@@ -510,6 +515,7 @@ export const useStoryStore = create<StoryState>()(
510
515
  state.historyIndex = 0;
511
516
  state.visitCounts = { [startPassage.name]: 1 };
512
517
  state.renderCounts = { [startPassage.name]: 1 };
518
+ state.renderDeferred = false;
513
519
  });
514
520
 
515
521
  lastNavigationVars = get().variables;
@@ -776,5 +782,17 @@ export const useStoryStore = create<StoryState>()(
776
782
  }
777
783
  return current;
778
784
  },
785
+
786
+ deferRender: () => {
787
+ set((state) => {
788
+ state.renderDeferred = true;
789
+ });
790
+ },
791
+
792
+ clearDeferredRender: () => {
793
+ set((state) => {
794
+ state.renderDeferred = false;
795
+ });
796
+ },
779
797
  })),
780
798
  );
package/src/story-api.ts CHANGED
@@ -50,6 +50,24 @@ import type { TransitionConfig } from './transition';
50
50
  export type { StoryAction };
51
51
  export type { MacroMetadata };
52
52
 
53
+ // Deferred-render promise lifecycle.
54
+ // deferRender() creates a promise; ready() resolves it.
55
+ // index.tsx reads getReadyPromise() AFTER render(), which is after both
56
+ // author JS and storyinit have run, so it always gets the final promise.
57
+ let readyResolve: (() => void) | null = null;
58
+ let readyPromise: Promise<void> | null = null;
59
+
60
+ /** Returns the current deferred-render promise, or null if not deferred. */
61
+ export function getReadyPromise(): Promise<void> | null {
62
+ return readyPromise;
63
+ }
64
+
65
+ /** Test-only: reset module-level promise state. */
66
+ export function _resetReadyState(): void {
67
+ readyResolve = null;
68
+ readyPromise = null;
69
+ }
70
+
53
71
  type NavigateCallback = (to: string, from: string) => void;
54
72
  type StoryInitCallback = () => void;
55
73
  type ActionsChangedCallback = () => void;
@@ -121,6 +139,8 @@ export interface StoryAPI {
121
139
  setCSS(enabled: boolean): void;
122
140
  setTransition(config: TransitionConfig | null): void;
123
141
  setNextTransition(config: TransitionConfig | null): void;
142
+ deferRender(): void;
143
+ ready(): void;
124
144
  random(): number;
125
145
  randomInt(min: number, max: number): number;
126
146
  readonly config: {
@@ -452,6 +472,21 @@ function createStoryAPI(): StoryAPI {
452
472
  useStoryStore.getState().setNextTransition(config);
453
473
  },
454
474
 
475
+ deferRender(): void {
476
+ useStoryStore.getState().deferRender();
477
+ readyPromise = new Promise<void>((resolve) => {
478
+ readyResolve = resolve;
479
+ });
480
+ },
481
+
482
+ ready(): void {
483
+ if (!readyResolve) return;
484
+ useStoryStore.getState().clearDeferredRender();
485
+ readyResolve();
486
+ readyResolve = null;
487
+ readyPromise = null;
488
+ },
489
+
455
490
  random(): number {
456
491
  return random();
457
492
  },