@rohal12/spindle 0.44.0 → 0.45.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.
@@ -0,0 +1,125 @@
1
+ //#region src/story-variables.ts
2
+ function declarationRegex(sigil) {
3
+ const escaped = sigil.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4
+ return new RegExp(`^${escaped}(\\w+)\\s*=\\s*(.+)$`);
5
+ }
6
+ var VAR_REF_RE = /\$(\w+(?:\.\w+)*)/g;
7
+ var FOR_LOCAL_RE = /\{for\s+@(\w+)(?:\s*,\s*@(\w+))?\s+of\b/g;
8
+ var VALID_VAR_TYPES = new Set([
9
+ "number",
10
+ "string",
11
+ "boolean"
12
+ ]);
13
+ function inferSchema(value) {
14
+ if (Array.isArray(value)) return { type: "array" };
15
+ if (value !== null && typeof value === "object") {
16
+ const fields = /* @__PURE__ */ new Map();
17
+ for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val));
18
+ return {
19
+ type: "object",
20
+ fields
21
+ };
22
+ }
23
+ const jsType = typeof value;
24
+ if (!VALID_VAR_TYPES.has(jsType)) throw new Error(`Unsupported type "${jsType}" for value ${String(value)}. Expected number, string, boolean, array, or object.`);
25
+ return { type: jsType };
26
+ }
27
+ /**
28
+ * Parse a StoryVariables or StoryTransients passage content into a schema map.
29
+ * Each line: `$varName = expression` (or `%varName = expression` for transients)
30
+ */
31
+ function parseStoryVariables(content, sigil = "$") {
32
+ const schema = /* @__PURE__ */ new Map();
33
+ const DECLARATION_RE = declarationRegex(sigil);
34
+ const passageName = sigil === "%" ? "StoryTransients" : "StoryVariables";
35
+ for (const rawLine of content.split("\n")) {
36
+ const line = rawLine.trim();
37
+ if (!line) continue;
38
+ const match = line.match(DECLARATION_RE);
39
+ if (!match) throw new Error(`${passageName}: Invalid declaration: "${line}". Expected: ${sigil}name = value`);
40
+ const [, name, expr] = match;
41
+ let value;
42
+ try {
43
+ value = new Function("return (" + expr + ")")();
44
+ } catch (err) {
45
+ throw new Error(`${passageName}: Failed to evaluate "${sigil}${name} = ${expr}": ${err instanceof Error ? err.message : err}`);
46
+ }
47
+ let fieldSchema;
48
+ try {
49
+ fieldSchema = inferSchema(value);
50
+ } catch (err) {
51
+ throw new Error(`${passageName}: ${err instanceof Error ? err.message : err}`);
52
+ }
53
+ schema.set(name, {
54
+ ...fieldSchema,
55
+ name,
56
+ default: value
57
+ });
58
+ }
59
+ return schema;
60
+ }
61
+ /**
62
+ * Extract for-loop local variable names from passage content.
63
+ * `{for @item of ...}` → "item"
64
+ * `{for @index, @item of ...}` → "index", "item"
65
+ */
66
+ function extractForLocals(content) {
67
+ const locals = /* @__PURE__ */ new Set();
68
+ let match;
69
+ FOR_LOCAL_RE.lastIndex = 0;
70
+ while ((match = FOR_LOCAL_RE.exec(content)) !== null) {
71
+ locals.add(match[1]);
72
+ if (match[2]) locals.add(match[2]);
73
+ }
74
+ return locals;
75
+ }
76
+ /**
77
+ * Validate a single variable reference path (e.g. "player.health") against
78
+ * the schema. Returns an error message or null if valid.
79
+ */
80
+ function validateRef(ref, schema, forLocals) {
81
+ const parts = ref.split(".");
82
+ const rootName = parts[0];
83
+ if (forLocals.has(rootName)) return null;
84
+ const rootSchema = schema.get(rootName);
85
+ if (!rootSchema) return `Undeclared variable: $${ref}`;
86
+ let current = rootSchema;
87
+ for (let i = 1; i < parts.length; i++) {
88
+ const part = parts[i];
89
+ if (current.type === "array") return null;
90
+ if (current.type !== "object" || !current.fields) return `Cannot access field "${part}" on $${parts.slice(0, i).join(".")} (type: ${current.type})`;
91
+ const fieldSchema = current.fields.get(part);
92
+ if (!fieldSchema) return null;
93
+ current = fieldSchema;
94
+ }
95
+ return null;
96
+ }
97
+ /**
98
+ * Scan all passages for $var references, check against schema.
99
+ * Returns list of error messages (empty = valid).
100
+ */
101
+ function validatePassages(passages, schema) {
102
+ const errors = [];
103
+ for (const [name, passage] of passages) {
104
+ if (name === "StoryVariables" || name === "StoryTransients") continue;
105
+ const forLocals = extractForLocals(passage.content);
106
+ let match;
107
+ VAR_REF_RE.lastIndex = 0;
108
+ while ((match = VAR_REF_RE.exec(passage.content)) !== null) {
109
+ const ref = match[1];
110
+ const error = validateRef(ref, schema, forLocals);
111
+ if (error) errors.push(`Passage "${name}": ${error}`);
112
+ }
113
+ }
114
+ return errors;
115
+ }
116
+ /**
117
+ * Extract default values from the schema as a plain object.
118
+ */
119
+ function extractDefaults(schema) {
120
+ const defaults = {};
121
+ for (const [name, varSchema] of schema) defaults[name] = varSchema.default;
122
+ return defaults;
123
+ }
124
+ //#endregion
125
+ export { extractDefaults, parseStoryVariables, validatePassages };
@@ -2,6 +2,8 @@ import { readFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
5
+ export { parseStoryVariables } from './story-variables.js';
6
+
5
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
8
 
7
9
  // Load built-in macro metadata generated at build time
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -35,6 +35,7 @@
35
35
  "dist/pkg/index.js",
36
36
  "dist/pkg/format.js",
37
37
  "dist/pkg/tooling.js",
38
+ "dist/pkg/story-variables.js",
38
39
  "dist/pkg/macro-registry.json",
39
40
  "types",
40
41
  "src"
@@ -11,12 +11,14 @@ export function App() {
11
11
 
12
12
  useEffect(() => {
13
13
  const onKeyDown = (e: KeyboardEvent) => {
14
- if (e.key === 'F6') {
14
+ const { quickSaveKey, quickLoadKey, save, load } =
15
+ useStoryStore.getState();
16
+ if (quickSaveKey !== null && e.key === quickSaveKey) {
15
17
  e.preventDefault();
16
- useStoryStore.getState().save();
17
- } else if (e.key === 'F9') {
18
+ save();
19
+ } else if (quickLoadKey !== null && e.key === quickLoadKey) {
18
20
  e.preventDefault();
19
- useStoryStore.getState().load();
21
+ load();
20
22
  }
21
23
  };
22
24
  document.addEventListener('keydown', onKeyDown);
@@ -12,7 +12,14 @@ interface PassageDialogProps {
12
12
  fallbackMarkup?: string;
13
13
  panelClass?: string;
14
14
  onClose: () => void;
15
+ /** Show the default `✕` button. Defaults to `dismissible`. */
15
16
  showCloseButton?: boolean;
17
+ /**
18
+ * When `false`, the player cannot dismiss the dialog: backdrop clicks are
19
+ * ignored and the `✕` button is hidden (unless `showCloseButton` is set
20
+ * explicitly). The dialog can still be closed programmatically.
21
+ */
22
+ dismissible?: boolean;
16
23
  }
17
24
 
18
25
  export function PassageDialog({
@@ -20,7 +27,8 @@ export function PassageDialog({
20
27
  fallbackMarkup,
21
28
  panelClass,
22
29
  onClose,
23
- showCloseButton = true,
30
+ dismissible = true,
31
+ showCloseButton = dismissible,
24
32
  }: PassageDialogProps) {
25
33
  // Stabilize onClose so DialogCloseContext value doesn't change identity
26
34
  const onCloseRef = useRef(onClose);
@@ -52,6 +60,7 @@ export function PassageDialog({
52
60
  }, [markup]);
53
61
 
54
62
  const handleBackdrop = (e: MouseEvent) => {
63
+ if (!dismissible) return;
55
64
  if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
56
65
  stableOnClose();
57
66
  }
@@ -59,6 +59,7 @@ export function TriggerDialogHost() {
59
59
  panelClass={dialog.panelClass}
60
60
  onClose={handleClose}
61
61
  showCloseButton={dialog.showCloseButton}
62
+ dismissible={dialog.dismissible}
62
63
  />
63
64
  ))}
64
65
  </>
@@ -13,7 +13,7 @@ interface MenubarActionConfig {
13
13
  title?: string;
14
14
  confirm?: string;
15
15
  hidden?: () => boolean;
16
- setup?: () => { perform: () => void; disabled?: boolean };
16
+ setup?: () => { perform: () => void; disabled?: boolean; title?: string };
17
17
  dialog?: {
18
18
  passageName: string;
19
19
  fallbackMarkup?: string;
@@ -22,6 +22,10 @@ interface MenubarActionConfig {
22
22
  };
23
23
  }
24
24
 
25
+ function hotkeyTitle(label: string, key: string | null): string {
26
+ return key ? `${label} (${key})` : label;
27
+ }
28
+
25
29
  export function defineMenubarAction(config: MenubarActionConfig) {
26
30
  defineMacro({
27
31
  name: config.name,
@@ -31,7 +35,7 @@ export function defineMenubarAction(config: MenubarActionConfig) {
31
35
  const [dialogOpen, setDialogOpen] = useState(false);
32
36
 
33
37
  const setup = config.setup?.() ?? { perform: () => {} };
34
- const { disabled } = setup;
38
+ const { disabled, title = config.title } = setup;
35
39
 
36
40
  const cls = ctx.className
37
41
  ? `menubar-button ${ctx.className}`
@@ -61,7 +65,7 @@ export function defineMenubarAction(config: MenubarActionConfig) {
61
65
  class={cls}
62
66
  onClick={perform}
63
67
  disabled={disabled}
64
- title={config.title}
68
+ title={title}
65
69
  >
66
70
  {config.label}
67
71
  </button>
@@ -104,9 +108,12 @@ defineMenubarAction({
104
108
  name: 'quicksave',
105
109
  label: 'QuickSave',
106
110
  actionType: 'save',
107
- title: 'Quick Save (F6)',
108
111
  setup: () => ({
109
112
  perform: useStoryStore((s) => s.save),
113
+ title: hotkeyTitle(
114
+ 'Quick Save',
115
+ useStoryStore((s) => s.quickSaveKey),
116
+ ),
110
117
  }),
111
118
  });
112
119
 
@@ -114,13 +121,17 @@ defineMenubarAction({
114
121
  name: 'quickload',
115
122
  label: 'QuickLoad',
116
123
  actionType: 'load',
117
- title: 'Quick Load (F9)',
118
124
  confirm: 'Load saved game? Current progress will be lost.',
119
125
  setup: () => {
120
126
  const load = useStoryStore((s) => s.load);
121
127
  const hasSave = useStoryStore((s) => s.hasSave);
128
+ const key = useStoryStore((s) => s.quickLoadKey);
122
129
  useStoryStore((s) => s.knownSaves);
123
- return { perform: () => load(), disabled: !hasSave() };
130
+ return {
131
+ perform: () => load(),
132
+ disabled: !hasSave(),
133
+ title: hotkeyTitle('Quick Load', key),
134
+ };
124
135
  },
125
136
  });
126
137
 
@@ -273,6 +273,28 @@ function slotMetaKey(ifid: string, slot?: string): string {
273
273
  : `${AUTOSAVE_KEY_PREFIX}${ifid}`;
274
274
  }
275
275
 
276
+ /** Record a named slot in the per-story slot index (no-op for the default slot). */
277
+ async function addToSlotIndex(ifid: string, slot?: string): Promise<void> {
278
+ if (slot == null) return;
279
+ const backend = await getBackend();
280
+ const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
281
+ const existing = (await backend.getMeta<string[]>(indexKey)) ?? [];
282
+ if (!existing.includes(slot)) {
283
+ await backend.setMeta(indexKey, [...existing, slot]);
284
+ }
285
+ }
286
+
287
+ function toSaveInfo(record: SaveRecord, slot?: string): SaveInfo {
288
+ return {
289
+ slot: slot ?? '',
290
+ title: record.meta.title,
291
+ passage: record.meta.passage,
292
+ createdAt: record.meta.createdAt,
293
+ updatedAt: record.meta.updatedAt,
294
+ custom: record.meta.custom ?? {},
295
+ };
296
+ }
297
+
276
298
  export async function quickSave(
277
299
  ifid: string,
278
300
  playthroughId: string,
@@ -296,15 +318,7 @@ export async function quickSave(
296
318
  ...custom,
297
319
  });
298
320
  await backend.setMeta(metaKey, record.meta.id);
299
-
300
- // Track named slots in the index
301
- if (slot != null) {
302
- const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
303
- const existing = (await backend.getMeta<string[]>(indexKey)) ?? [];
304
- if (!existing.includes(slot)) {
305
- await backend.setMeta(indexKey, [...existing, slot]);
306
- }
307
- }
321
+ await addToSlotIndex(ifid, slot);
308
322
 
309
323
  return record;
310
324
  }
@@ -371,14 +385,7 @@ export async function getSlotSaveInfo(
371
385
  if (!existingId) return null;
372
386
  const record = await backend.getSave(existingId);
373
387
  if (!record) return null;
374
- return {
375
- slot: slot ?? '',
376
- title: record.meta.title,
377
- passage: record.meta.passage,
378
- createdAt: record.meta.createdAt,
379
- updatedAt: record.meta.updatedAt,
380
- custom: record.meta.custom ?? {},
381
- };
388
+ return toSaveInfo(record, slot);
382
389
  }
383
390
 
384
391
  /**
@@ -487,7 +494,12 @@ export async function exportSave(
487
494
  };
488
495
  }
489
496
 
490
- export async function importSave(
497
+ /**
498
+ * Validate an export against the running story and build the record to store:
499
+ * a fresh save ID, and the playthrough record created if it doesn't exist yet.
500
+ * The returned record is not stored yet.
501
+ */
502
+ async function prepareImport(
491
503
  data: SaveExport,
492
504
  ifid: string,
493
505
  ): Promise<SaveRecord> {
@@ -521,10 +533,67 @@ export async function importSave(
521
533
  await backend.putPlaythrough(pt);
522
534
  }
523
535
 
524
- await backend.putSave(record);
525
536
  return record;
526
537
  }
527
538
 
539
+ export async function importSave(
540
+ data: SaveExport,
541
+ ifid: string,
542
+ ): Promise<SaveRecord> {
543
+ const record = await prepareImport(data, ifid);
544
+ await (await getBackend()).putSave(record);
545
+ return record;
546
+ }
547
+
548
+ /**
549
+ * Export the save held in a slot (default autosave slot when `slot` is omitted).
550
+ * Returns undefined if the slot is empty.
551
+ */
552
+ export async function exportSlotSave(
553
+ ifid: string,
554
+ slot?: string,
555
+ ): Promise<SaveExport | undefined> {
556
+ const saveId = await (
557
+ await getBackend()
558
+ ).getMeta<string>(slotMetaKey(ifid, slot));
559
+ if (!saveId) return undefined;
560
+ return exportSave(saveId);
561
+ }
562
+
563
+ /**
564
+ * Import an exported save into a slot (default autosave slot when `slot` is
565
+ * omitted), replacing any save already in that slot. Applies the same
566
+ * validation as `importSave` (version, IFID) and keeps the playthrough record
567
+ * and slot index consistent.
568
+ */
569
+ export async function importSlotSave(
570
+ data: SaveExport,
571
+ ifid: string,
572
+ slot?: string,
573
+ ): Promise<SaveInfo> {
574
+ const record = await prepareImport(data, ifid);
575
+
576
+ // The slot keys in `custom` describe where the save lives, so they follow
577
+ // the target slot rather than the slot the save was exported from.
578
+ const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
579
+ record.meta.custom = {
580
+ ...custom,
581
+ isAutosave: !slot,
582
+ ...(slot != null ? { slot } : {}),
583
+ };
584
+
585
+ const backend = await getBackend();
586
+ const metaKey = slotMetaKey(ifid, slot);
587
+ const previousId = await backend.getMeta<string>(metaKey);
588
+
589
+ await backend.putSave(record);
590
+ await backend.setMeta(metaKey, record.meta.id);
591
+ await addToSlotIndex(ifid, slot);
592
+ if (previousId) await backend.deleteSave(previousId);
593
+
594
+ return toSaveInfo(record, slot);
595
+ }
596
+
528
597
  // --- Storage Management ---
529
598
 
530
599
  export async function getStorageInfo(ifid: string): Promise<StorageInfo> {
package/src/store.ts CHANGED
@@ -8,7 +8,13 @@ import {
8
8
  } from 'immer';
9
9
  import type { StoryData } from './parser';
10
10
  import type { TransitionConfig } from './transition';
11
- import type { SavePayload, SaveHistoryMoment, SaveInfo } from './saves/types';
11
+ import type {
12
+ SavePayload,
13
+ SaveHistoryMoment,
14
+ SaveInfo,
15
+ SaveExport,
16
+ } from './saves/types';
17
+ import { isSaveExport } from './saves/types';
12
18
  import { executeStoryInit } from './story-init';
13
19
  import { emit } from './event-emitter';
14
20
  import { resetTriggers } from './triggers';
@@ -22,6 +28,8 @@ import {
22
28
  getSlotSaveInfo,
23
29
  listSlotSaves,
24
30
  deleteSlotSave,
31
+ exportSlotSave,
32
+ importSlotSave,
25
33
  saveSession,
26
34
  clearSession,
27
35
  clearGameData as smClearGameData,
@@ -218,6 +226,11 @@ export function _resetRuntimePhase(): void {
218
226
  // Store
219
227
  // ---------------------------------------------------------------------------
220
228
 
229
+ /** A hotkey is a `KeyboardEvent.key` value; anything else disables it. */
230
+ function normalizeHotkey(key: unknown): string | null {
231
+ return typeof key === 'string' && key !== '' ? key : null;
232
+ }
233
+
221
234
  /** Restore or reset PRNG from a history moment's snapshot. */
222
235
  function restorePRNGFromMoment(moment: HistoryMoment | undefined): void {
223
236
  if (moment?.prng) {
@@ -248,6 +261,8 @@ export interface StoryState {
248
261
  knownSaves: Record<string, true>;
249
262
  playthroughId: string;
250
263
  maxHistory: number;
264
+ quickSaveKey: string | null;
265
+ quickLoadKey: string | null;
251
266
  saveError: string | null;
252
267
  loadError: string | null;
253
268
  transitionConfig: TransitionConfig | null;
@@ -256,6 +271,8 @@ export interface StoryState {
256
271
  renderDeferred: boolean;
257
272
 
258
273
  setMaxHistory: (limit: number) => void;
274
+ setQuickSaveKey: (key: string | null) => void;
275
+ setQuickLoadKey: (key: string | null) => void;
259
276
  init: (
260
277
  storyData: StoryData,
261
278
  variableDefaults?: Record<string, unknown>,
@@ -278,6 +295,8 @@ export interface StoryState {
278
295
  getSaveInfo: (slot?: string) => Promise<SaveInfo | null>;
279
296
  listSaves: () => Promise<SaveInfo[]>;
280
297
  deleteSave: (slot?: string) => void;
298
+ exportSave: (slot?: string) => Promise<SaveExport | null>;
299
+ importSave: (data: unknown, slot?: string) => Promise<SaveInfo>;
281
300
  clearGameData: () => void;
282
301
  clearAllData: () => void;
283
302
  deletePlaythrough: (playthroughId: string) => void;
@@ -307,6 +326,8 @@ export const useStoryStore = create<StoryState>()(
307
326
  knownSaves: {},
308
327
  playthroughId: '',
309
328
  maxHistory: 40,
329
+ quickSaveKey: 'F6',
330
+ quickLoadKey: 'F9',
310
331
  saveError: null,
311
332
  loadError: null,
312
333
  transitionConfig: null,
@@ -320,6 +341,18 @@ export const useStoryStore = create<StoryState>()(
320
341
  });
321
342
  },
322
343
 
344
+ setQuickSaveKey: (key: string | null) => {
345
+ set((state) => {
346
+ state.quickSaveKey = normalizeHotkey(key);
347
+ });
348
+ },
349
+
350
+ setQuickLoadKey: (key: string | null) => {
351
+ set((state) => {
352
+ state.quickLoadKey = normalizeHotkey(key);
353
+ });
354
+ },
355
+
323
356
  init: (
324
357
  storyData: StoryData,
325
358
  variableDefaults: Record<string, unknown> = {},
@@ -697,6 +730,24 @@ export const useStoryStore = create<StoryState>()(
697
730
  });
698
731
  },
699
732
 
733
+ exportSave: async (slot?: string): Promise<SaveExport | null> => {
734
+ const { storyData } = get();
735
+ if (!storyData) return null;
736
+ return (await exportSlotSave(storyData.ifid, slot)) ?? null;
737
+ },
738
+
739
+ importSave: async (data: unknown, slot?: string): Promise<SaveInfo> => {
740
+ const { storyData } = get();
741
+ if (!storyData) throw new Error('spindle: Story is not initialized.');
742
+ if (!isSaveExport(data)) throw new Error('Invalid save file format');
743
+
744
+ const info = await importSlotSave(data, storyData.ifid, slot);
745
+ set((state) => {
746
+ state.knownSaves = { ...state.knownSaves, [slot ?? '']: true };
747
+ });
748
+ return info;
749
+ },
750
+
700
751
  clearGameData: () => {
701
752
  const { storyData } = get();
702
753
  if (!storyData) return;
package/src/story-api.ts CHANGED
@@ -10,6 +10,7 @@ import { settings } from './settings';
10
10
  import type {
11
11
  SavePayload,
12
12
  SaveInfo,
13
+ SaveExport,
13
14
  StorageInfo,
14
15
  StorageQuota,
15
16
  } from './saves/types';
@@ -158,6 +159,8 @@ export interface StoryAPI {
158
159
  getSaveInfo(slot?: string): Promise<SaveInfo | null>;
159
160
  listSaves(): Promise<SaveInfo[]>;
160
161
  deleteSave(slot?: string): void;
162
+ exportSave(slot?: string): Promise<SaveExport | null>;
163
+ importSave(data: unknown, slot?: string): Promise<SaveInfo>;
161
164
  visited(name?: string): number;
162
165
  hasVisited(name?: string): boolean;
163
166
  hasVisitedAny(...names: string[]): boolean;
@@ -199,7 +202,11 @@ export interface StoryAPI {
199
202
  unwatch(name: string): void;
200
203
  openDialog(
201
204
  passageName: string,
202
- options?: { panelClass?: string; showCloseButton?: boolean },
205
+ options?: {
206
+ panelClass?: string;
207
+ showCloseButton?: boolean;
208
+ dismissible?: boolean;
209
+ },
203
210
  ): void;
204
211
  closeDialog(): void;
205
212
  closeAllDialogs(): void;
@@ -214,6 +221,8 @@ export interface StoryAPI {
214
221
  randomInt(min: number, max: number): number;
215
222
  readonly config: {
216
223
  maxHistory: number;
224
+ quickSaveKey: string | null;
225
+ quickLoadKey: string | null;
217
226
  };
218
227
  readonly prng: {
219
228
  init(seed?: string, useEntropy?: boolean): void;
@@ -304,6 +313,14 @@ function createStoryAPI(): StoryAPI {
304
313
  useStoryStore.getState().deleteSave(slot);
305
314
  },
306
315
 
316
+ exportSave(slot?: string): Promise<SaveExport | null> {
317
+ return useStoryStore.getState().exportSave(slot);
318
+ },
319
+
320
+ importSave(data: unknown, slot?: string): Promise<SaveInfo> {
321
+ return useStoryStore.getState().importSave(data, slot);
322
+ },
323
+
307
324
  visited(name?: string): number {
308
325
  const state = useStoryStore.getState();
309
326
  return state.visitCounts[name ?? state.currentPassage] ?? 0;
@@ -494,12 +511,17 @@ function createStoryAPI(): StoryAPI {
494
511
 
495
512
  openDialog(
496
513
  passageName: string,
497
- options?: { panelClass?: string; showCloseButton?: boolean },
514
+ options?: {
515
+ panelClass?: string;
516
+ showCloseButton?: boolean;
517
+ dismissible?: boolean;
518
+ },
498
519
  ): void {
499
520
  pushDialog({
500
521
  passageName,
501
522
  panelClass: options?.panelClass,
502
523
  showCloseButton: options?.showCloseButton,
524
+ dismissible: options?.dismissible,
503
525
  });
504
526
  },
505
527
 
@@ -562,6 +584,18 @@ function createStoryAPI(): StoryAPI {
562
584
  set maxHistory(limit: number) {
563
585
  useStoryStore.getState().setMaxHistory(limit);
564
586
  },
587
+ get quickSaveKey(): string | null {
588
+ return useStoryStore.getState().quickSaveKey;
589
+ },
590
+ set quickSaveKey(key: string | null) {
591
+ useStoryStore.getState().setQuickSaveKey(key);
592
+ },
593
+ get quickLoadKey(): string | null {
594
+ return useStoryStore.getState().quickLoadKey;
595
+ },
596
+ set quickLoadKey(key: string | null) {
597
+ useStoryStore.getState().setQuickLoadKey(key);
598
+ },
565
599
  },
566
600
 
567
601
  prng: {
package/src/triggers.ts CHANGED
@@ -14,6 +14,7 @@ export interface QueuedDialog {
14
14
  passageName: string;
15
15
  panelClass?: string;
16
16
  showCloseButton?: boolean;
17
+ dismissible?: boolean;
17
18
  }
18
19
 
19
20
  interface Trigger {
@@ -13,3 +13,13 @@ import type { StoryAPI as PublishedAPI } from '../types/index';
13
13
  const _sourceToPublished: PublishedAPI = {} as SourceAPI;
14
14
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
15
15
  const _publishedToSource: SourceAPI = {} as PublishedAPI;
16
+
17
+ // Tooling entry point (`@rohal12/spindle/tooling`): types/tooling.d.ts must
18
+ // match the parser that dist/pkg/tooling.js re-exports.
19
+ import type { parseStoryVariables as SourceParse } from './story-variables';
20
+ import type { parseStoryVariables as PublishedParse } from '../types/tooling';
21
+
22
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
23
+ const _parseSourceToPublished: typeof PublishedParse = {} as typeof SourceParse;
24
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
25
+ const _parsePublishedToSource: typeof SourceParse = {} as typeof PublishedParse;