@rohal12/spindle 0.59.6 → 0.59.8

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.
@@ -10096,18 +10096,18 @@ function historyQueries(visitCounts, renderCounts) {
10096
10096
  };
10097
10097
  }
10098
10098
  var cachedFns = null;
10099
- var cachedVisitCounts = null;
10100
- var cachedRenderCounts = null;
10099
+ /**
10100
+ * The functions compiled code calls. The history queries read the counters
10101
+ * when called, not when the functions were built: mutation code that
10102
+ * navigates (`Story.goto()`) goes on with the counters of the new state,
10103
+ * as the Story API does.
10104
+ */
10101
10105
  function buildExpressionFns() {
10102
- const { visitCounts, renderCounts } = useStoryStore.getState();
10103
- if (cachedFns && cachedVisitCounts === visitCounts && cachedRenderCounts === renderCounts) return cachedFns;
10104
- cachedFns = {
10105
- ...historyQueries(() => visitCounts, () => renderCounts),
10106
+ cachedFns ??= {
10107
+ ...historyQueries(() => useStoryStore.getState().visitCounts, () => useStoryStore.getState().renderCounts),
10106
10108
  random,
10107
10109
  randomInt
10108
10110
  };
10109
- cachedVisitCounts = visitCounts;
10110
- cachedRenderCounts = renderCounts;
10111
10111
  return cachedFns;
10112
10112
  }
10113
10113
  /**
@@ -12575,10 +12575,18 @@ var defineData = (target, key, value) => {
12575
12575
  });
12576
12576
  };
12577
12577
  /**
12578
+ * A reducer extracts string keys, so devalue never sees the symbol keys of
12579
+ * the original object: refuse them here, as it does for a plain object.
12580
+ */
12581
+ function refuseSymbolKeys(value) {
12582
+ if (Object.getOwnPropertySymbols(value).length > 0) throw new TypeError("spindle: Cannot save an object with symbol keys");
12583
+ }
12584
+ /**
12578
12585
  * Own enumerable keys of `value`, as a plain object; for an error also its
12579
12586
  * message, cause and (AggregateError) errors, which are not enumerable.
12580
12587
  */
12581
12588
  function ownData(value) {
12589
+ refuseSymbolKeys(value);
12582
12590
  const data = {};
12583
12591
  if (value instanceof Error) {
12584
12592
  for (const key of ERROR_HIDDEN_KEYS$1) if (hasOwn(value, key)) defineData(data, key, value[key]);
@@ -12605,6 +12613,7 @@ function reducers() {
12605
12613
  if (!isObject(v) || Object.getPrototypeOf(v) !== Object.prototype) return false;
12606
12614
  const keys = Object.keys(v);
12607
12615
  if (!keys.some(isEscapedOneCharKey)) return false;
12616
+ refuseSymbolKeys(v);
12608
12617
  if (keys.includes("__proto__")) throw new TypeError("spindle: Cannot save a property named \"__proto__\"");
12609
12618
  return keys.flatMap((k) => [k, v[k]]);
12610
12619
  };
@@ -13167,24 +13176,28 @@ function createIDBBackend() {
13167
13176
  }
13168
13177
  var _backend = null;
13169
13178
  async function detectBackend() {
13170
- if (typeof indexedDB !== "undefined") try {
13171
- await new Promise((resolve, reject) => {
13172
- const req = indexedDB.open("__spindle_probe__", 1);
13173
- req.onsuccess = () => {
13174
- req.result.close();
13175
- indexedDB.deleteDatabase("__spindle_probe__");
13176
- resolve();
13177
- };
13178
- req.onerror = () => reject(req.error);
13179
- });
13180
- return createIDBBackend();
13179
+ try {
13180
+ if (typeof indexedDB !== "undefined") {
13181
+ await new Promise((resolve, reject) => {
13182
+ const req = indexedDB.open("__spindle_probe__", 1);
13183
+ req.onsuccess = () => {
13184
+ req.result.close();
13185
+ indexedDB.deleteDatabase("__spindle_probe__");
13186
+ resolve();
13187
+ };
13188
+ req.onerror = () => reject(req.error);
13189
+ });
13190
+ return createIDBBackend();
13191
+ }
13181
13192
  } catch {}
13182
- if (typeof localStorage !== "undefined") try {
13183
- const probeKey = "__spindle_probe__";
13184
- localStorage.setItem(probeKey, "1");
13185
- const val = localStorage.getItem(probeKey);
13186
- localStorage.removeItem(probeKey);
13187
- if (val === "1") return createLocalStorageBackend();
13193
+ try {
13194
+ if (typeof localStorage !== "undefined") {
13195
+ const probeKey = "__spindle_probe__";
13196
+ localStorage.setItem(probeKey, "1");
13197
+ const val = localStorage.getItem(probeKey);
13198
+ localStorage.removeItem(probeKey);
13199
+ if (val === "1") return createLocalStorageBackend();
13200
+ }
13188
13201
  } catch {}
13189
13202
  return createMemoryBackend();
13190
13203
  }
@@ -13496,6 +13509,13 @@ function strictPairs(ab = /* @__PURE__ */ new Map(), ba = /* @__PURE__ */ new Ma
13496
13509
  return pairs;
13497
13510
  }
13498
13511
  /**
13512
+ * Whether `a` and `b` are equal as deepEqual() has it and also share
13513
+ * references at the same places (a save of one loads as the other).
13514
+ */
13515
+ function deepEqualStrict(a, b) {
13516
+ return equal(a, b, strictPairs());
13517
+ }
13518
+ /**
13499
13519
  * `curr` with each part that equals (see deepEqualStrict) the part of
13500
13520
  * `prev` at the same place replaced by `prev`'s: the history moments of a
13501
13521
  * save then share what did not change between them, which a save stores
@@ -13765,13 +13785,14 @@ function underChange(path, keys) {
13765
13785
  }
13766
13786
  /**
13767
13787
  * The segment standing for the entry `index` of a Map or Set in a path, or
13768
- * for the `cause` or `errors` of an Error ('c', 'e').
13788
+ * for the `cause` or `errors` of an Error ('c', 'e'), or for the buffer a
13789
+ * typed array or DataView views ('b').
13769
13790
  */
13770
13791
  var entryKey = (kind, index) => `\0${kind}${index}`;
13771
13792
  var isEntryKey = (segment) => segment.startsWith("\0");
13772
13793
  /**
13773
- * What a Map, Set or Error holds in place of properties (keys and values,
13774
- * members, `cause` and `errors`), each with the segment standing for it in
13794
+ * What a Map, Set, Error or view holds in place of properties (keys and
13795
+ * values, members, `cause` and `errors`, the buffer), each with the segment standing for it in
13775
13796
  * a path (see entryKey); undefined for any other value.
13776
13797
  */
13777
13798
  function entryChildren(value) {
@@ -13787,7 +13808,8 @@ function entryChildren(value) {
13787
13808
  for (const member of value) children.push([entryKey("s", index++), member]);
13788
13809
  } else if (value instanceof Error) {
13789
13810
  for (const key of ["cause", "errors"]) if (hasOwn(value, key)) children.push([entryKey(key === "cause" ? "c" : "e", 0), value[key]]);
13790
- } else return;
13811
+ } else if (ArrayBuffer.isView(value)) children.push([entryKey("b", 0), value.buffer]);
13812
+ else return;
13791
13813
  return children;
13792
13814
  }
13793
13815
  /**
@@ -14245,7 +14267,9 @@ function storedRecord(meta, payload) {
14245
14267
  };
14246
14268
  }
14247
14269
  var createSave = queued(createSaveNow);
14248
- async function createSaveNow(ifid, playthroughId, payload, custom = {}) {
14270
+ async function createSaveNow(ifid, playthroughPromise, payload, custom = {}) {
14271
+ const playthroughId = await playthroughPromise;
14272
+ if (!playthroughId) throw new Error("No playthrough");
14249
14273
  const now = (/* @__PURE__ */ new Date()).toISOString();
14250
14274
  const record = storedRecord({
14251
14275
  id: randomUUID(),
@@ -14261,6 +14285,10 @@ async function createSaveNow(ifid, playthroughId, payload, custom = {}) {
14261
14285
  return record;
14262
14286
  }
14263
14287
  /**
14288
+ * A playthrough passed as a promise (see resolvePlaythroughId) is awaited
14289
+ * inside the operation, so the call takes its place in the order of
14290
+ * operations when it is made.
14291
+ *
14264
14292
  * Replace the payload of an existing save, keeping its ID and `createdAt`.
14265
14293
  * Pass the current `playthroughId` when the new payload comes from the
14266
14294
  * running game: the save then holds that playthrough's state, so it is
@@ -14276,7 +14304,8 @@ async function createSaveNow(ifid, playthroughId, payload, custom = {}) {
14276
14304
  * before, so slot keys and metadata not passed again are kept.
14277
14305
  */
14278
14306
  var overwriteSave = queued(overwriteSaveNow);
14279
- async function overwriteSaveNow(saveId, payload, custom, playthroughId) {
14307
+ async function overwriteSaveNow(saveId, payload, custom, playthroughPromise) {
14308
+ const playthroughId = await playthroughPromise;
14280
14309
  const backend = await getBackend();
14281
14310
  const existing = await backend.getSave(saveId);
14282
14311
  if (!existing) return void 0;
@@ -14438,7 +14467,7 @@ function toSaveInfo(record, slot) {
14438
14467
  * (settled by operations issued before this call), so the save takes its
14439
14468
  * place in the order of operations when it is called.
14440
14469
  */
14441
- function quickSave(ifid, playthroughId, payload, slot, custom) {
14470
+ function quickSave$1(ifid, playthroughId, payload, slot, custom) {
14442
14471
  return inOrder(async () => {
14443
14472
  const ptId = await playthroughId;
14444
14473
  const existingId = await slotSaveId(ifid, slot);
@@ -14524,12 +14553,18 @@ var SESSION_KEY_PREFIX = "spindle.session.";
14524
14553
  * encodePayload). Throws, like a save, when the state holds a value a save
14525
14554
  * cannot (a function, an instance of an unregistered class, a unique
14526
14555
  * symbol); the session then keeps its previous copy.
14556
+ *
14557
+ * A storage that is unavailable or full is returned, not thrown: the session
14558
+ * keeps its previous copy then too, but the story can go on, so the caller
14559
+ * decides how to tell the player.
14527
14560
  */
14528
14561
  function saveSession(ifid, payload) {
14529
14562
  const text = JSON.stringify(encodePayload(payload));
14530
14563
  try {
14531
14564
  sessionStorage.setItem(`${SESSION_KEY_PREFIX}${ifid}`, text);
14532
- } catch {}
14565
+ } catch (error) {
14566
+ return { error };
14567
+ }
14533
14568
  }
14534
14569
  /**
14535
14570
  * The live payload of the stored session, if there is one that loads. A
@@ -14646,14 +14681,14 @@ var clearAllData = queued(clearAllDataNow);
14646
14681
  async function clearAllDataNow() {
14647
14682
  await (await getBackend()).destroy();
14648
14683
  resetBackend();
14649
- if (typeof sessionStorage !== "undefined") {
14684
+ try {
14650
14685
  const toRemove = [];
14651
14686
  for (let i = 0; i < sessionStorage.length; i++) {
14652
14687
  const key = sessionStorage.key(i);
14653
14688
  if (key?.startsWith("spindle.session.")) toRemove.push(key);
14654
14689
  }
14655
14690
  for (const key of toRemove) sessionStorage.removeItem(key);
14656
- }
14691
+ } catch {}
14657
14692
  }
14658
14693
  /**
14659
14694
  * Delete a playthrough with its saves, emptying the slots that held them.
@@ -14983,9 +15018,18 @@ function relink(draft, work, changes) {
14983
15018
  function cloneNamespaces(from) {
14984
15019
  const seen = /* @__PURE__ */ new Map();
14985
15020
  return {
14986
- variables: deepClone(from.variables, { seen }),
14987
- temporary: deepClone(from.temporary, { seen }),
14988
- transient: deepClone(from.transient, { seen })
15021
+ variables: deepClone(from.variables, {
15022
+ keepUnregistered: true,
15023
+ seen
15024
+ }),
15025
+ temporary: deepClone(from.temporary, {
15026
+ keepUnregistered: true,
15027
+ seen
15028
+ }),
15029
+ transient: deepClone(from.transient, {
15030
+ keepUnregistered: true,
15031
+ seen
15032
+ })
14989
15033
  };
14990
15034
  }
14991
15035
  /** The three variable namespaces of `state`, as an object to walk. */
@@ -15550,7 +15594,7 @@ var sessionMoments = [];
15550
15594
  */
15551
15595
  function persistSession(get) {
15552
15596
  const { storyData, currentPassage, variables, history, historyIndex, visitCounts, renderCounts } = get();
15553
- if (!storyData) return;
15597
+ if (!storyData) return void 0;
15554
15598
  if (sessionMoments.length > history.length) sessionMoments.length = history.length;
15555
15599
  if (sessionMoments.length < history.length) if (history.length - sessionMoments.length === 1) {
15556
15600
  const i = history.length - 1;
@@ -15558,7 +15602,7 @@ function persistSession(get) {
15558
15602
  } else forEachRecorded(history.length, (vars, i) => {
15559
15603
  if (i >= sessionMoments.length) sessionMoments[i] = savedMoment(history[i], vars);
15560
15604
  });
15561
- saveSession(storyData.ifid, {
15605
+ return saveSession(storyData.ifid, {
15562
15606
  passage: currentPassage,
15563
15607
  variables,
15564
15608
  history: sessionMoments,
@@ -15584,7 +15628,8 @@ var NAVIGATION_ERROR_CONTEXT = "The story could not go to another passage:";
15584
15628
  function persistSessionThen(get, after) {
15585
15629
  let failure;
15586
15630
  try {
15587
- persistSession(get);
15631
+ const storageFailure = persistSession(get);
15632
+ if (storageFailure) showRuntimeError(SESSION_ERROR_CONTEXT, storageFailure.error);
15588
15633
  } catch (error) {
15589
15634
  failure = { error };
15590
15635
  showRuntimeError(SESSION_ERROR_CONTEXT, error);
@@ -16278,7 +16323,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
16278
16323
  set((state) => {
16279
16324
  state.saveError = null;
16280
16325
  });
16281
- await quickSave(storyData.ifid, playthrough, payload, slot, custom);
16326
+ await quickSave$1(storyData.ifid, playthrough, payload, slot, custom);
16282
16327
  recordKnownSave(set, slot, true);
16283
16328
  }), "spindle: failed to save", (err) => set((state) => {
16284
16329
  state.saveError = errorMessage(err, "Failed to save");
@@ -16491,8 +16536,19 @@ var FOCUSABLE = [
16491
16536
  "[contenteditable=\"true\"]",
16492
16537
  "[tabindex]:not([tabindex=\"-1\"])"
16493
16538
  ].join(",");
16539
+ /**
16540
+ * Whether CSS leaves `el` in the tab sequence: not `visibility: hidden`
16541
+ * (inherited, so the computed value covers ancestors) and not inside a
16542
+ * `display: none` element.
16543
+ */
16544
+ function isRendered(el) {
16545
+ const { visibility } = getComputedStyle(el);
16546
+ if (visibility === "hidden" || visibility === "collapse") return false;
16547
+ for (let e = el; e; e = e.parentElement) if (getComputedStyle(e).display === "none") return false;
16548
+ return true;
16549
+ }
16494
16550
  function focusables(root) {
16495
- return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el) => !el.hidden && !el.closest("[hidden], [inert]"));
16551
+ return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el) => !el.hidden && !el.closest("[hidden], [inert]") && isRendered(el));
16496
16552
  }
16497
16553
  /** Open modal dialogs, innermost last. Only the topmost handles keys. */
16498
16554
  var openModals = [];
@@ -16708,18 +16764,39 @@ function RuntimeErrors() {
16708
16764
  });
16709
16765
  }
16710
16766
  //#endregion
16767
+ //#region src/quick-actions.ts
16768
+ /**
16769
+ * QuickSave and QuickLoad as the built-in buttons and hotkeys perform them:
16770
+ * a failure is shown on the page (the Story API's save() and load() only
16771
+ * reject, for the code that awaits them).
16772
+ */
16773
+ async function quickSave() {
16774
+ try {
16775
+ await useStoryStore.getState().save();
16776
+ } catch (error) {
16777
+ showRuntimeError("The game could not be saved:", error);
16778
+ }
16779
+ }
16780
+ async function quickLoad() {
16781
+ try {
16782
+ await useStoryStore.getState().load();
16783
+ } catch (error) {
16784
+ showRuntimeError("The saved game could not be loaded:", error);
16785
+ }
16786
+ }
16787
+ //#endregion
16711
16788
  //#region src/components/App.tsx
16712
16789
  function App() {
16713
16790
  const { storyData, currentPassage, nobr } = useStoryFields("storyData", "currentPassage", "nobr");
16714
16791
  useEffect(() => {
16715
16792
  const onKeyDown = (e) => {
16716
- const { quickSaveKey, quickLoadKey, save, load } = useStoryStore.getState();
16793
+ const { quickSaveKey, quickLoadKey } = useStoryStore.getState();
16717
16794
  if (quickSaveKey !== null && e.key === quickSaveKey) {
16718
16795
  e.preventDefault();
16719
- save();
16796
+ quickSave();
16720
16797
  } else if (quickLoadKey !== null && e.key === quickLoadKey) {
16721
16798
  e.preventDefault();
16722
- load();
16799
+ quickLoad();
16723
16800
  }
16724
16801
  };
16725
16802
  document.addEventListener("keydown", onKeyDown);
@@ -16734,10 +16811,10 @@ function App() {
16734
16811
  /* @__PURE__ */ jsx(TriggerDialogHost, {}),
16735
16812
  /* @__PURE__ */ jsx(RuntimeErrors, {})
16736
16813
  ] });
16737
- return nobr ? /* @__PURE__ */ jsx(NobrContext.Provider, {
16738
- value: true,
16814
+ return /* @__PURE__ */ jsx(NobrContext.Provider, {
16815
+ value: nobr,
16739
16816
  children: content
16740
- }) : content;
16817
+ });
16741
16818
  }
16742
16819
  //#endregion
16743
16820
  //#region src/parser.ts
@@ -16897,7 +16974,9 @@ function storageKey() {
16897
16974
  return `spindle.${useStoryStore.getState().storyData?.ifid || "unknown"}.settings`;
16898
16975
  }
16899
16976
  function persist() {
16900
- localStorage.setItem(storageKey(), JSON.stringify(values));
16977
+ try {
16978
+ localStorage.setItem(storageKey(), JSON.stringify(values));
16979
+ } catch {}
16901
16980
  }
16902
16981
  function loadFromStorage() {
16903
16982
  if (storageLoaded) return;
@@ -18181,7 +18260,13 @@ function widgetDef({ name = "", parameters = "" }) {
18181
18260
  function parseWidgetDef(rawArgs) {
18182
18261
  return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
18183
18262
  }
18184
- var BLOCK_WIDGET = /\{widget\s+["']?(\w+)["']?[^}]*\}([\s\S]*?)\{\/widget\}/g;
18263
+ /**
18264
+ * A definition: its arguments (read like a {widget} macro's, see
18265
+ * parseWidgetDef) and its body. Macro names are not case-sensitive.
18266
+ */
18267
+ var WIDGET_DEFINITION = /\{widget\s+([^}]*)\}([\s\S]*?)\{\/widget\}/gi;
18268
+ /** `{@children}`, also with selectors such as `{.highlight @children}`. */
18269
+ var CHILDREN_PLACEHOLDER = /\{[^{}]*@children\s*\}/;
18185
18270
  /**
18186
18271
  * The names of the block widgets (those whose body renders `{@children}`)
18187
18272
  * that StoryInit and the passages tagged `widget` define. They must be
@@ -18192,7 +18277,12 @@ function blockWidgetNames(passages) {
18192
18277
  const names = [];
18193
18278
  for (const passage of passages) {
18194
18279
  if (passage.name !== "StoryInit" && !passage.tags?.includes("widget")) continue;
18195
- for (const match of passage.content.matchAll(BLOCK_WIDGET)) if (/\{@children\}/.test(match[2])) names.push(match[1]);
18280
+ for (const match of passage.content.matchAll(WIDGET_DEFINITION)) {
18281
+ if (!CHILDREN_PLACEHOLDER.test(match[2])) continue;
18282
+ try {
18283
+ names.push(parseWidgetDef(match[1]).name);
18284
+ } catch {}
18285
+ }
18196
18286
  }
18197
18287
  return names;
18198
18288
  }
@@ -18779,7 +18869,7 @@ function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRe
18779
18869
  logMacroError(`computed ${rawArgs}`, err, location);
18780
18870
  return;
18781
18871
  }
18782
- if (!deepEqual(prevRef.current, newValue)) {
18872
+ if (!deepEqualStrict(prevRef.current, newValue)) {
18783
18873
  prevRef.current = newValue;
18784
18874
  if (isLocal) try {
18785
18875
  localsUpdate(name, newValue);
@@ -19186,7 +19276,10 @@ defineMacro({
19186
19276
  * Return a function that runs a macro body once, outside the passage tree:
19187
19277
  * it renders `children` into a detached node, so the body's macros ({set},
19188
19278
  * {if}, {goto}, ...) fire their side effects through the normal Preact
19189
- * pipeline, then unmounts it. Used by {button} and {link} on click.
19279
+ * pipeline. Used by {button} and {link} on click. The body stays mounted
19280
+ * until the owning macro unmounts (its passage changes, say), so work it
19281
+ * starts in an effect, such as the timer of a {timed} or {repeat}, can
19282
+ * finish; the passage leaving cancels it.
19190
19283
  *
19191
19284
  * A detached render starts with no contexts, so the hook captures the ones
19192
19285
  * the owning macro sees and provides them again: the locals scope (read
@@ -19207,6 +19300,10 @@ function useDetachedBody() {
19207
19300
  const widgetChildren = useContext(WidgetChildrenContext);
19208
19301
  const repeat = useContext(RepeatContext);
19209
19302
  const closeDialog = useContext(DialogCloseContext);
19303
+ const mounted = useRef([]);
19304
+ useEffect(() => () => {
19305
+ for (const container of mounted.current.splice(0)) render(null, container);
19306
+ }, []);
19210
19307
  const run = (children) => {
19211
19308
  const locals = liveLocalsView(updater.getValues);
19212
19309
  const container = document.createElement("div");
@@ -19236,7 +19333,7 @@ function useDetachedBody() {
19236
19333
  })
19237
19334
  })
19238
19335
  }), container);
19239
- render(null, container);
19336
+ mounted.current.push(container);
19240
19337
  };
19241
19338
  return (children) => runWithCommittedMutations(() => run(children));
19242
19339
  }
@@ -19553,9 +19650,9 @@ defineMenubarAction({
19553
19650
  label: "QuickSave",
19554
19651
  actionType: "save",
19555
19652
  setup: () => {
19556
- const { save, quickSaveKey } = useStoryFields("save", "quickSaveKey");
19653
+ const { quickSaveKey } = useStoryFields("quickSaveKey");
19557
19654
  return {
19558
- perform: save,
19655
+ perform: quickSave,
19559
19656
  title: hotkeyTitle("Quick Save", quickSaveKey)
19560
19657
  };
19561
19658
  }
@@ -19566,9 +19663,9 @@ defineMenubarAction({
19566
19663
  actionType: "load",
19567
19664
  confirm: "Load saved game? Current progress will be lost.",
19568
19665
  setup: () => {
19569
- const { load, hasSave, quickLoadKey } = useStoryFields("load", "hasSave", "quickLoadKey", "knownSaves");
19666
+ const { hasSave, quickLoadKey } = useStoryFields("hasSave", "quickLoadKey", "knownSaves");
19570
19667
  return {
19571
- perform: () => load(),
19668
+ perform: quickLoad,
19572
19669
  disabled: !hasSave(),
19573
19670
  title: hotkeyTitle("Quick Load", quickLoadKey)
19574
19671
  };
@@ -19662,6 +19759,7 @@ function SaveManagerContent() {
19662
19759
  const [status, setStatus] = useState(null);
19663
19760
  const [collapsed, setCollapsed] = useState(() => /* @__PURE__ */ new Set());
19664
19761
  const [renamingId, setRenamingId] = useState(null);
19762
+ const renameCancelled = useRef(false);
19665
19763
  const [renameValue, setRenameValue] = useState("");
19666
19764
  const renameInputRef = useRef(null);
19667
19765
  const fileInputRef = useRef(null);
@@ -19715,9 +19813,7 @@ function SaveManagerContent() {
19715
19813
  const playthrough = resolvePlaythroughId();
19716
19814
  try {
19717
19815
  await saveWithHooks(void 0, void 0, beginSave, async (payload) => {
19718
- const ptId = await playthrough;
19719
- if (!ptId) throw new Error("No playthrough");
19720
- return createSave(ifid, ptId, payload);
19816
+ return createSave(ifid, playthrough, payload);
19721
19817
  });
19722
19818
  showStatus("Save created");
19723
19819
  await refresh();
@@ -19729,7 +19825,7 @@ function SaveManagerContent() {
19729
19825
  const playthrough = resolvePlaythroughId();
19730
19826
  try {
19731
19827
  await saveWithHooks(void 0, void 0, beginSave, async (payload) => {
19732
- if (!await overwriteSave(saveId, payload, void 0, await playthrough || void 0)) throw new Error("Save not found");
19828
+ if (!await overwriteSave(saveId, payload, void 0, playthrough)) throw new Error("Save not found");
19733
19829
  });
19734
19830
  showStatus("Save overwritten");
19735
19831
  await refresh();
@@ -19762,6 +19858,7 @@ function SaveManagerContent() {
19762
19858
  }
19763
19859
  };
19764
19860
  const handleRenameStart = (save) => {
19861
+ renameCancelled.current = false;
19765
19862
  setRenamingId(save.meta.id);
19766
19863
  setRenameValue(save.meta.title);
19767
19864
  };
@@ -19778,7 +19875,15 @@ function SaveManagerContent() {
19778
19875
  };
19779
19876
  const handleRenameKeyDown = (e) => {
19780
19877
  if (e.key === "Enter") handleRenameConfirm();
19781
- else if (e.key === "Escape") setRenamingId(null);
19878
+ else if (e.key === "Escape") {
19879
+ e.preventDefault();
19880
+ e.stopPropagation();
19881
+ renameCancelled.current = true;
19882
+ setRenamingId(null);
19883
+ }
19884
+ };
19885
+ const handleRenameBlur = () => {
19886
+ if (!renameCancelled.current) handleRenameConfirm();
19782
19887
  };
19783
19888
  const handleExport = async (saveId) => {
19784
19889
  try {
@@ -19895,7 +20000,7 @@ function SaveManagerContent() {
19895
20000
  value: renameValue,
19896
20001
  onInput: (e) => setRenameValue(e.target.value),
19897
20002
  onKeyDown: handleRenameKeyDown,
19898
- onBlur: handleRenameConfirm
20003
+ onBlur: handleRenameBlur
19899
20004
  }) : /* @__PURE__ */ jsx("div", {
19900
20005
  class: "save-slot-title",
19901
20006
  children: save.meta.title
@@ -20538,6 +20643,19 @@ function cleanupSnapshots(containerEl) {
20538
20643
  const snapshots = containerEl.querySelectorAll(".passage-snapshot");
20539
20644
  for (let i = 0; i < snapshots.length; i++) snapshots[i].remove();
20540
20645
  }
20646
+ /**
20647
+ * `config` for players who prefer reduced motion: the CSS shortens the
20648
+ * animations (see styles.css), so the waits that follow them shrink too,
20649
+ * leaving no empty passage area before the next passage.
20650
+ */
20651
+ function reduceMotion(config) {
20652
+ if (!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return config;
20653
+ return {
20654
+ ...config,
20655
+ duration: Math.min(config.duration, 10),
20656
+ pause: 0
20657
+ };
20658
+ }
20541
20659
  defineMacro({
20542
20660
  name: "passage",
20543
20661
  interpolate: true,
@@ -20566,13 +20684,13 @@ defineMacro({
20566
20684
  useEffect(() => {
20567
20685
  const store = useStoryStore.getState();
20568
20686
  const { history, historyIndex, transitionConfig } = store;
20569
- const next = store.consumeNextTransition();
20570
20687
  const prevLen = prevHistoryLenRef.current;
20571
20688
  prevHistoryLenRef.current = history.length;
20572
20689
  const isFirstLoad = !displayedPassage;
20573
20690
  const isRestart = history.length === 1 && historyIndex === 0 && prevLen > 1;
20574
20691
  const isLoad = history.length > 1 && historyIndex === history.length - 1 && prevLen > 1 && Math.abs(history.length - prevLen) > 1;
20575
20692
  if (navigationId === displayed.id) return;
20693
+ const next = store.consumeNextTransition();
20576
20694
  cancelTransition();
20577
20695
  const targetPassage = storyData?.passages.get(currentPassage);
20578
20696
  if (!targetPassage) {
@@ -20585,7 +20703,7 @@ defineMacro({
20585
20703
  setDisplayedPassage(currentPassage);
20586
20704
  return;
20587
20705
  }
20588
- const config = resolveTransition(targetPassage.tags, next, transitionConfig);
20706
+ const config = reduceMotion(resolveTransition(targetPassage.tags, next, transitionConfig));
20589
20707
  resolvedTypeRef.current = config.type;
20590
20708
  const containerEl = containerRef.current;
20591
20709
  if (config.type === "none") {
@@ -20725,7 +20843,7 @@ defineMacro({
20725
20843
  });
20726
20844
  //#endregion
20727
20845
  //#region src/styles.css?inline
20728
- var styles_default = "*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml {\n font-size: 16px;\n -webkit-text-size-adjust: 100%;\n}\n\nbody {\n margin: 0;\n padding: 0;\n font-family: Georgia, 'Times New Roman', serif;\n line-height: 1.75;\n color: #e0e0e0;\n background-color: #1a1a2e;\n min-height: 100vh;\n}\n\ntw-storydata {\n display: none !important;\n}\n\n#story {\n max-width: 42em;\n margin: 0 auto;\n padding: 2em 1.5em;\n}\n\n/* Passage transitions */\n\n.passage-container {\n position: relative;\n}\n\n.passage-container--crossfading {\n display: grid;\n}\n\n.passage-container--crossfading > * {\n grid-area: 1 / 1;\n}\n\n.passage {\n animation: passage-fade-in var(--passage-in-duration, 0.3s) ease;\n}\n\n.passage[data-transition='none'] {\n animation: none;\n}\n\n.passage-snapshot {\n animation: passage-fade-out var(--passage-out-duration, 0.3s) ease forwards;\n pointer-events: none;\n user-select: none;\n}\n\n@keyframes passage-fade-in {\n from {\n opacity: 0;\n transform: translateY(8px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n@keyframes passage-fade-out {\n from {\n opacity: 1;\n transform: translateY(0);\n }\n to {\n opacity: 0;\n transform: translateY(-8px);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .passage,\n .passage-snapshot {\n animation-duration: 0.01s !important;\n }\n}\n\n.macro-button {\n background: rgba(100, 181, 246, 0.15);\n color: #64b5f6;\n border: 1px solid rgba(100, 181, 246, 0.4);\n border-radius: 4px;\n padding: 0.25em 0.75em;\n font: inherit;\n font-size: 0.95em;\n cursor: pointer;\n transition:\n background 0.2s,\n border-color 0.2s;\n}\n\n.macro-button:hover {\n background: rgba(100, 181, 246, 0.25);\n border-color: #90caf9;\n}\n\n.macro-button:active {\n background: rgba(100, 181, 246, 0.35);\n}\n\n.error {\n color: #ef5350;\n font-family: monospace;\n padding: 1em;\n border: 1px solid #ef5350;\n border-radius: 4px;\n background: rgba(239, 83, 80, 0.1);\n}\n\n/* Runtime errors shown on the page (see src/components/RuntimeErrors.tsx) */\n.spindle-error-banners {\n position: fixed;\n top: 0.75em;\n left: 50%;\n z-index: 300;\n display: flex;\n flex-direction: column;\n gap: 0.5em;\n width: min(42em, calc(100% - 1.5em));\n transform: translateX(-50%);\n}\n\n.spindle-error-banner {\n position: relative;\n padding: 0.75em 2.75em 0.75em 1em;\n border: 1px solid #ef5350;\n border-radius: 4px;\n background: #2b1a1e;\n color: #ffcdd2;\n font-size: 0.9em;\n line-height: 1.5;\n box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);\n}\n\n.spindle-error-banner-message {\n font-family: monospace;\n color: #ef9a9a;\n}\n\n.spindle-error-banner-dismiss {\n position: absolute;\n top: 0.5em;\n right: 0.5em;\n background: transparent;\n border: none;\n color: inherit;\n font-size: 1em;\n cursor: pointer;\n}\n\n.spindle-error-banner-dismiss:hover {\n color: #fff;\n}\n\n.loading {\n text-align: center;\n padding: 2em;\n opacity: 0.6;\n}\n\n/* Menu bar */\n\n.story-menubar {\n position: sticky;\n top: 0;\n z-index: 100;\n display: flex;\n align-items: center;\n gap: 0.5em;\n padding: 0.5em 1.5em;\n background: #16213e;\n border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n font-family: system-ui, sans-serif;\n font-size: 0.9em;\n}\n\n.story-title {\n flex: 1;\n font-weight: 600;\n color: #e0e0e0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.menubar-button {\n background: transparent;\n color: #9e9e9e;\n border: 1px solid transparent;\n padding: 0.35em 0.7em;\n border-radius: 4px;\n cursor: pointer;\n font: inherit;\n transition:\n color 0.2s,\n background 0.2s;\n}\n\n.menubar-button:hover {\n color: #e0e0e0;\n background: rgba(255, 255, 255, 0.08);\n}\n\n.menubar-button:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n.menubar-button:disabled:hover {\n color: #9e9e9e;\n background: transparent;\n}\n\n/* Generic dialog */\n\n.dialog-overlay {\n position: fixed;\n inset: 0;\n z-index: 200;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.6);\n}\n\n.dialog-panel {\n background: #16213e;\n border: 1px solid rgba(255, 255, 255, 0.1);\n border-radius: 8px;\n width: 90%;\n max-width: 500px;\n max-height: 80vh;\n display: flex;\n flex-direction: column;\n color: #e0e0e0;\n font-family: system-ui, sans-serif;\n position: relative;\n}\n\n/* Focused programmatically when the dialog has no focusable content */\n.dialog-panel:focus {\n outline: none;\n}\n\n.dialog-saves {\n max-width: 550px;\n}\n\n.dialog-settings {\n max-width: 400px;\n}\n\n.dialog-close {\n position: absolute;\n top: 0.75em;\n right: 0.75em;\n z-index: 1;\n background: transparent;\n border: none;\n color: #9e9e9e;\n font-size: 1.1em;\n cursor: pointer;\n padding: 0.25em;\n line-height: 1;\n}\n\n.dialog-close:hover {\n color: #e0e0e0;\n}\n\n.dialog-body {\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n}\n\n/* Settings controls */\n\n.settings-controls {\n padding: 1em 1.25em;\n display: flex;\n flex-direction: column;\n gap: 1em;\n}\n\n.settings-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1em;\n}\n\n.settings-row select,\n.settings-row input[type='range'] {\n accent-color: #64b5f6;\n}\n\n.settings-row select {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n padding: 0.3em 0.5em;\n font: inherit;\n}\n\n.settings-row input[type='checkbox'] {\n accent-color: #64b5f6;\n width: 1.1em;\n height: 1.1em;\n}\n\n/* Save manager */\n\n.save-manager {\n display: flex;\n flex-direction: column;\n flex: 1;\n min-height: 0;\n}\n\n.saves-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 1em 1.25em;\n padding-right: 3em;\n border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n font-weight: 600;\n flex-shrink: 0;\n}\n\n.saves-header-left {\n display: flex;\n align-items: center;\n gap: 0.75em;\n}\n\n.saves-mode-toggle {\n display: flex;\n gap: 0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n overflow: hidden;\n}\n\n.saves-mode-toggle button {\n background: transparent;\n color: #9e9e9e;\n border: none;\n padding: 0.3em 0.8em;\n font: inherit;\n font-size: 0.85em;\n font-weight: 600;\n cursor: pointer;\n transition:\n background 0.2s,\n color 0.2s;\n}\n\n.saves-mode-toggle button.active {\n background: rgba(100, 181, 246, 0.2);\n color: #64b5f6;\n}\n\n.saves-mode-toggle button:hover:not(.active) {\n color: #e0e0e0;\n}\n\n.saves-body {\n padding: 0.75em 1.25em;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n}\n\n.saves-empty {\n text-align: center;\n padding: 2em 1em;\n opacity: 0.5;\n font-size: 0.9em;\n}\n\n.saves-toolbar {\n display: flex;\n gap: 0.5em;\n padding: 0.5em 1.25em;\n border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n flex-shrink: 0;\n}\n\n.saves-toolbar-button {\n background: rgba(255, 255, 255, 0.05);\n color: #9e9e9e;\n border: 1px solid rgba(255, 255, 255, 0.1);\n border-radius: 4px;\n padding: 0.3em 0.7em;\n font: inherit;\n font-size: 0.8em;\n cursor: pointer;\n transition:\n background 0.2s,\n color 0.2s;\n}\n\n.saves-toolbar-button:hover {\n background: rgba(255, 255, 255, 0.1);\n color: #e0e0e0;\n}\n\n.playthrough-group {\n margin-bottom: 0.75em;\n}\n\n.playthrough-header {\n display: flex;\n align-items: center;\n gap: 0.5em;\n padding: 0.4em 0;\n cursor: pointer;\n user-select: none;\n font-size: 0.85em;\n color: #9e9e9e;\n transition: color 0.2s;\n}\n\n.playthrough-header:hover {\n color: #e0e0e0;\n}\n\n.playthrough-chevron {\n display: inline-block;\n font-size: 0.7em;\n transition: transform 0.2s;\n}\n\n.playthrough-chevron.open {\n transform: rotate(90deg);\n}\n\n.playthrough-label {\n font-weight: 600;\n}\n\n.playthrough-date {\n opacity: 0.6;\n font-size: 0.9em;\n}\n\n.playthrough-saves {\n display: flex;\n flex-direction: column;\n gap: 0.4em;\n padding-left: 0.25em;\n}\n\n.save-slot {\n display: flex;\n align-items: center;\n gap: 0.75em;\n padding: 0.5em 0.6em;\n background: rgba(255, 255, 255, 0.03);\n border: 1px solid rgba(255, 255, 255, 0.06);\n border-radius: 6px;\n transition: background 0.15s;\n}\n\n.save-slot:hover {\n background: rgba(255, 255, 255, 0.06);\n}\n\n.save-slot-info {\n flex: 1;\n min-width: 0;\n}\n\n.save-slot-title {\n font-weight: 500;\n font-size: 0.9em;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.save-slot-meta {\n font-size: 0.75em;\n color: #9e9e9e;\n display: flex;\n gap: 0.75em;\n}\n\n.save-slot-actions {\n display: flex;\n gap: 0.3em;\n flex-shrink: 0;\n}\n\n.save-slot-action {\n background: transparent;\n color: #9e9e9e;\n border: 1px solid transparent;\n border-radius: 3px;\n padding: 0.2em 0.5em;\n font: inherit;\n font-size: 0.75em;\n cursor: pointer;\n transition:\n color 0.2s,\n background 0.2s;\n white-space: nowrap;\n}\n\n.save-slot-action:hover {\n color: #e0e0e0;\n background: rgba(255, 255, 255, 0.08);\n}\n\n.save-slot-action.primary {\n color: #64b5f6;\n border-color: rgba(100, 181, 246, 0.3);\n}\n\n.save-slot-action.primary:hover {\n background: rgba(100, 181, 246, 0.15);\n}\n\n.save-slot-action.danger:hover {\n color: #ef5350;\n background: rgba(239, 83, 80, 0.1);\n}\n\n.save-slot-new {\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 0.5em;\n margin-top: 0.25em;\n border: 1px dashed rgba(255, 255, 255, 0.12);\n border-radius: 6px;\n color: #9e9e9e;\n font: inherit;\n font-size: 0.8em;\n background: transparent;\n cursor: pointer;\n width: 100%;\n transition:\n color 0.2s,\n border-color 0.2s,\n background 0.2s;\n}\n\n.save-slot-new:hover {\n color: #64b5f6;\n border-color: rgba(100, 181, 246, 0.3);\n background: rgba(100, 181, 246, 0.05);\n}\n\n.saves-status {\n padding: 0.5em 1.25em;\n font-size: 0.8em;\n color: #81c784;\n border-top: 1px solid rgba(255, 255, 255, 0.08);\n flex-shrink: 0;\n text-align: center;\n}\n\n.saves-status.error {\n color: #ef5350;\n border: none;\n border-radius: 0;\n padding: 0.5em 1.25em;\n font-family: inherit;\n background: transparent;\n}\n\n.save-rename-input {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(100, 181, 246, 0.4);\n border-radius: 3px;\n padding: 0.15em 0.4em;\n font: inherit;\n font-size: 0.9em;\n width: 100%;\n}\n\n.save-rename-input:focus {\n outline: none;\n border-color: #64b5f6;\n}\n\n/* Macro: link */\n\n.macro-link {\n color: #64b5f6;\n text-decoration: none;\n border-bottom: 1px solid rgba(100, 181, 246, 0.3);\n transition:\n color 0.2s,\n border-color 0.2s;\n cursor: pointer;\n}\n\n.macro-link:hover {\n color: #90caf9;\n border-bottom-color: #90caf9;\n}\n\n.macro-link:active {\n color: #42a5f5;\n}\n\n/* Macro: form inputs */\n\n.macro-textbox,\n.macro-numberbox,\n.macro-textarea {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n padding: 0.3em 0.5em;\n font: inherit;\n}\n\n.macro-textbox:focus,\n.macro-numberbox:focus,\n.macro-textarea:focus {\n outline: none;\n border-color: #64b5f6;\n}\n\n.macro-textarea {\n min-height: 3em;\n resize: vertical;\n}\n\n.macro-checkbox,\n.macro-radiobutton {\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n gap: 0.3em;\n}\n\n.macro-checkbox input,\n.macro-radiobutton input {\n accent-color: #64b5f6;\n}\n\n/* Macro: listbox */\n\n.macro-listbox {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n padding: 0.3em 0.5em;\n font: inherit;\n}\n\n/* Macro: cycle */\n\n.macro-cycle {\n background: rgba(100, 181, 246, 0.15);\n color: #64b5f6;\n border: 1px solid rgba(100, 181, 246, 0.4);\n border-radius: 4px;\n padding: 0.25em 0.75em;\n font: inherit;\n cursor: pointer;\n transition:\n background 0.2s,\n border-color 0.2s;\n}\n\n.macro-cycle:hover {\n background: rgba(100, 181, 246, 0.25);\n border-color: #90caf9;\n}\n\n/* Macro: meter */\n\n.macro-meter {\n position: relative;\n width: 100%;\n height: 1.4em;\n background: #2a2a3e;\n border-radius: 0.3em;\n overflow: hidden;\n border: 1px solid #444;\n}\n\n.macro-meter-fill {\n height: 100%;\n background: #64b5f6;\n transition: width 0.3s ease;\n}\n\n.macro-meter-label {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 0.85em;\n color: #e0e0e0;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);\n pointer-events: none;\n}\n\n/* Markdown wrapper — invisible to layout */\n\n.md {\n display: contents;\n}\n\n/* Markdown block elements inside passages */\n\n.passage p {\n margin: 0.6em 0;\n}\n\n.passage p:first-child {\n margin-top: 0;\n}\n\n.passage p:last-child {\n margin-bottom: 0;\n}\n\n.passage h1,\n.passage h2,\n.passage h3,\n.passage h4,\n.passage h5,\n.passage h6 {\n margin: 1em 0 0.4em;\n line-height: 1.3;\n color: #f0f0f0;\n}\n\n.passage h1 {\n font-size: 1.8em;\n}\n.passage h2 {\n font-size: 1.5em;\n}\n.passage h3 {\n font-size: 1.25em;\n}\n.passage h4 {\n font-size: 1.1em;\n}\n.passage h5 {\n font-size: 1em;\n}\n.passage h6 {\n font-size: 0.9em;\n opacity: 0.8;\n}\n\n.passage code {\n font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;\n font-size: 0.9em;\n background: rgba(255, 255, 255, 0.08);\n padding: 0.15em 0.35em;\n border-radius: 3px;\n}\n\n.passage pre {\n background: rgba(0, 0, 0, 0.3);\n border: 1px solid rgba(255, 255, 255, 0.08);\n border-radius: 6px;\n padding: 1em;\n overflow-x: auto;\n margin: 0.8em 0;\n}\n\n.passage pre code {\n background: none;\n padding: 0;\n border-radius: 0;\n font-size: 0.85em;\n}\n\n.passage blockquote {\n border-left: 3px solid rgba(100, 181, 246, 0.5);\n margin: 0.8em 0;\n padding: 0.4em 1em;\n color: #b0b0b0;\n}\n\n.passage blockquote p {\n margin: 0.3em 0;\n}\n\n.passage ul,\n.passage ol {\n margin: 0.6em 0;\n padding-left: 1.8em;\n}\n\n.passage li {\n margin: 0.2em 0;\n}\n\n.passage hr {\n border: none;\n border-top: 1px solid rgba(255, 255, 255, 0.12);\n margin: 1.2em 0;\n}\n\n.passage table {\n border-collapse: collapse;\n width: 100%;\n margin: 0.8em 0;\n}\n\n.passage th,\n.passage td {\n border: 1px solid rgba(255, 255, 255, 0.12);\n padding: 0.5em 0.75em;\n text-align: left;\n}\n\n.passage th {\n background: rgba(255, 255, 255, 0.06);\n font-weight: 600;\n}\n\n.passage tr:nth-child(even) {\n background: rgba(255, 255, 255, 0.02);\n}\n\n.passage del {\n opacity: 0.5;\n}\n\n.passage strong {\n color: #f0f0f0;\n}\n\n/* Macro: type (typewriter) */\n\n.macro-type-cursor::after {\n content: '\\2588';\n animation: type-blink 0.7s step-end infinite;\n color: #64b5f6;\n}\n\n.macro-type-done .macro-type-cursor::after {\n display: none;\n}\n\n@keyframes type-blink {\n 50% {\n opacity: 0;\n }\n}\n";
20846
+ var styles_default = "*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml {\n font-size: 16px;\n -webkit-text-size-adjust: 100%;\n}\n\nbody {\n margin: 0;\n padding: 0;\n font-family: Georgia, 'Times New Roman', serif;\n line-height: 1.75;\n color: #e0e0e0;\n background-color: #1a1a2e;\n min-height: 100vh;\n}\n\ntw-storydata {\n display: none !important;\n}\n\n#story {\n max-width: 42em;\n margin: 0 auto;\n padding: 2em 1.5em;\n}\n\n/* Passage transitions */\n\n.passage-container {\n position: relative;\n}\n\n.passage-container--crossfading {\n display: grid;\n}\n\n.passage-container--crossfading > * {\n grid-area: 1 / 1;\n}\n\n.passage {\n animation: passage-fade-in var(--passage-in-duration, 0.3s) ease;\n}\n\n.passage[data-transition='none'] {\n animation: none;\n}\n\n.passage-snapshot {\n animation: passage-fade-out var(--passage-out-duration, 0.3s) ease forwards;\n pointer-events: none;\n user-select: none;\n}\n\n@keyframes passage-fade-in {\n from {\n opacity: 0;\n transform: translateY(8px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n@keyframes passage-fade-out {\n from {\n opacity: 1;\n transform: translateY(0);\n }\n to {\n opacity: 0;\n transform: translateY(-8px);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .passage,\n .passage-snapshot {\n animation-duration: 0.01s !important;\n }\n}\n\n.macro-button {\n background: rgba(100, 181, 246, 0.15);\n color: #64b5f6;\n border: 1px solid rgba(100, 181, 246, 0.4);\n border-radius: 4px;\n padding: 0.25em 0.75em;\n font: inherit;\n font-size: 0.95em;\n cursor: pointer;\n transition:\n background 0.2s,\n border-color 0.2s;\n}\n\n.macro-button:hover {\n background: rgba(100, 181, 246, 0.25);\n border-color: #90caf9;\n}\n\n.macro-button:active {\n background: rgba(100, 181, 246, 0.35);\n}\n\n.error {\n color: #ef5350;\n font-family: monospace;\n padding: 1em;\n border: 1px solid #ef5350;\n border-radius: 4px;\n background: rgba(239, 83, 80, 0.1);\n}\n\n/* Runtime errors shown on the page (see src/components/RuntimeErrors.tsx) */\n.spindle-error-banners {\n position: fixed;\n top: 0.75em;\n left: 50%;\n z-index: 300;\n display: flex;\n flex-direction: column;\n gap: 0.5em;\n width: min(42em, calc(100% - 1.5em));\n transform: translateX(-50%);\n}\n\n.spindle-error-banner {\n position: relative;\n padding: 0.75em 2.75em 0.75em 1em;\n border: 1px solid #ef5350;\n border-radius: 4px;\n background: #2b1a1e;\n color: #ffcdd2;\n font-size: 0.9em;\n line-height: 1.5;\n box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);\n}\n\n.spindle-error-banner-message {\n font-family: monospace;\n color: #ef9a9a;\n}\n\n.spindle-error-banner-dismiss {\n position: absolute;\n top: 0.5em;\n right: 0.5em;\n background: transparent;\n border: none;\n color: inherit;\n font-size: 1em;\n cursor: pointer;\n}\n\n.spindle-error-banner-dismiss:hover {\n color: #fff;\n}\n\n.loading {\n text-align: center;\n padding: 2em;\n opacity: 0.6;\n}\n\n/* Menu bar */\n\n.story-menubar {\n position: sticky;\n top: 0;\n z-index: 100;\n display: flex;\n align-items: center;\n gap: 0.5em;\n padding: 0.5em 1.5em;\n background: #16213e;\n border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n font-family: system-ui, sans-serif;\n font-size: 0.9em;\n}\n\n.story-title {\n flex: 1;\n font-weight: 600;\n color: #e0e0e0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.menubar-button {\n background: transparent;\n color: #9e9e9e;\n border: 1px solid transparent;\n padding: 0.35em 0.7em;\n border-radius: 4px;\n cursor: pointer;\n font: inherit;\n transition:\n color 0.2s,\n background 0.2s;\n}\n\n.menubar-button:hover {\n color: #e0e0e0;\n background: rgba(255, 255, 255, 0.08);\n}\n\n.menubar-button:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n.menubar-button:disabled:hover {\n color: #9e9e9e;\n background: transparent;\n}\n\n/* Generic dialog */\n\n.dialog-overlay {\n position: fixed;\n inset: 0;\n z-index: 200;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.6);\n}\n\n.dialog-panel {\n background: #16213e;\n border: 1px solid rgba(255, 255, 255, 0.1);\n border-radius: 8px;\n width: 90%;\n max-width: 500px;\n max-height: 80vh;\n display: flex;\n flex-direction: column;\n color: #e0e0e0;\n font-family: system-ui, sans-serif;\n position: relative;\n}\n\n/* Focused programmatically when the dialog has no focusable content */\n.dialog-panel:focus {\n outline: none;\n}\n\n.dialog-saves {\n max-width: 550px;\n}\n\n.dialog-settings {\n max-width: 400px;\n}\n\n/* Keep the first control clear of the close button above it */\n.dialog-settings .dialog-body {\n padding-top: 2.25em;\n}\n\n.dialog-close {\n position: absolute;\n top: 0.75em;\n right: 0.75em;\n z-index: 1;\n background: transparent;\n border: none;\n color: #9e9e9e;\n font-size: 1.1em;\n cursor: pointer;\n padding: 0.25em;\n line-height: 1;\n}\n\n.dialog-close:hover {\n color: #e0e0e0;\n}\n\n.dialog-body {\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n}\n\n/* Settings controls */\n\n.settings-controls {\n padding: 1em 1.25em;\n display: flex;\n flex-direction: column;\n gap: 1em;\n}\n\n.settings-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1em;\n}\n\n.settings-row select,\n.settings-row input[type='range'] {\n accent-color: #64b5f6;\n}\n\n.settings-row select {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n padding: 0.3em 0.5em;\n font: inherit;\n}\n\n.settings-row input[type='checkbox'] {\n accent-color: #64b5f6;\n width: 1.1em;\n height: 1.1em;\n}\n\n/* Save manager */\n\n.save-manager {\n display: flex;\n flex-direction: column;\n flex: 1;\n min-height: 0;\n}\n\n.saves-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 1em 1.25em;\n padding-right: 3em;\n border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n font-weight: 600;\n flex-shrink: 0;\n}\n\n.saves-header-left {\n display: flex;\n align-items: center;\n gap: 0.75em;\n}\n\n.saves-mode-toggle {\n display: flex;\n gap: 0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n overflow: hidden;\n}\n\n.saves-mode-toggle button {\n background: transparent;\n color: #9e9e9e;\n border: none;\n padding: 0.3em 0.8em;\n font: inherit;\n font-size: 0.85em;\n font-weight: 600;\n cursor: pointer;\n transition:\n background 0.2s,\n color 0.2s;\n}\n\n.saves-mode-toggle button.active {\n background: rgba(100, 181, 246, 0.2);\n color: #64b5f6;\n}\n\n.saves-mode-toggle button:hover:not(.active) {\n color: #e0e0e0;\n}\n\n.saves-body {\n padding: 0.75em 1.25em;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n}\n\n.saves-empty {\n text-align: center;\n padding: 2em 1em;\n opacity: 0.5;\n font-size: 0.9em;\n}\n\n.saves-toolbar {\n display: flex;\n gap: 0.5em;\n padding: 0.5em 1.25em;\n border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n flex-shrink: 0;\n}\n\n.saves-toolbar-button {\n background: rgba(255, 255, 255, 0.05);\n color: #9e9e9e;\n border: 1px solid rgba(255, 255, 255, 0.1);\n border-radius: 4px;\n padding: 0.3em 0.7em;\n font: inherit;\n font-size: 0.8em;\n cursor: pointer;\n transition:\n background 0.2s,\n color 0.2s;\n}\n\n.saves-toolbar-button:hover {\n background: rgba(255, 255, 255, 0.1);\n color: #e0e0e0;\n}\n\n.playthrough-group {\n margin-bottom: 0.75em;\n}\n\n.playthrough-header {\n display: flex;\n align-items: center;\n gap: 0.5em;\n padding: 0.4em 0;\n cursor: pointer;\n user-select: none;\n font-size: 0.85em;\n color: #9e9e9e;\n transition: color 0.2s;\n}\n\n.playthrough-header:hover {\n color: #e0e0e0;\n}\n\n.playthrough-chevron {\n display: inline-block;\n font-size: 0.7em;\n transition: transform 0.2s;\n}\n\n.playthrough-chevron.open {\n transform: rotate(90deg);\n}\n\n.playthrough-label {\n font-weight: 600;\n}\n\n.playthrough-date {\n opacity: 0.6;\n font-size: 0.9em;\n}\n\n.playthrough-saves {\n display: flex;\n flex-direction: column;\n gap: 0.4em;\n padding-left: 0.25em;\n}\n\n.save-slot {\n display: flex;\n align-items: center;\n gap: 0.75em;\n padding: 0.5em 0.6em;\n background: rgba(255, 255, 255, 0.03);\n border: 1px solid rgba(255, 255, 255, 0.06);\n border-radius: 6px;\n transition: background 0.15s;\n}\n\n.save-slot:hover {\n background: rgba(255, 255, 255, 0.06);\n}\n\n.save-slot-info {\n flex: 1;\n min-width: 0;\n}\n\n.save-slot-title {\n font-weight: 500;\n font-size: 0.9em;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.save-slot-meta {\n font-size: 0.75em;\n color: #9e9e9e;\n display: flex;\n gap: 0.75em;\n}\n\n.save-slot-actions {\n display: flex;\n gap: 0.3em;\n flex-shrink: 0;\n}\n\n.save-slot-action {\n background: transparent;\n color: #9e9e9e;\n border: 1px solid transparent;\n border-radius: 3px;\n padding: 0.2em 0.5em;\n font: inherit;\n font-size: 0.75em;\n cursor: pointer;\n transition:\n color 0.2s,\n background 0.2s;\n white-space: nowrap;\n}\n\n.save-slot-action:hover {\n color: #e0e0e0;\n background: rgba(255, 255, 255, 0.08);\n}\n\n.save-slot-action.primary {\n color: #64b5f6;\n border-color: rgba(100, 181, 246, 0.3);\n}\n\n.save-slot-action.primary:hover {\n background: rgba(100, 181, 246, 0.15);\n}\n\n.save-slot-action.danger:hover {\n color: #ef5350;\n background: rgba(239, 83, 80, 0.1);\n}\n\n.save-slot-new {\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 0.5em;\n margin-top: 0.25em;\n border: 1px dashed rgba(255, 255, 255, 0.12);\n border-radius: 6px;\n color: #9e9e9e;\n font: inherit;\n font-size: 0.8em;\n background: transparent;\n cursor: pointer;\n width: 100%;\n transition:\n color 0.2s,\n border-color 0.2s,\n background 0.2s;\n}\n\n.save-slot-new:hover {\n color: #64b5f6;\n border-color: rgba(100, 181, 246, 0.3);\n background: rgba(100, 181, 246, 0.05);\n}\n\n.saves-status {\n padding: 0.5em 1.25em;\n font-size: 0.8em;\n color: #81c784;\n border-top: 1px solid rgba(255, 255, 255, 0.08);\n flex-shrink: 0;\n text-align: center;\n}\n\n.saves-status.error {\n color: #ef5350;\n border: none;\n border-radius: 0;\n padding: 0.5em 1.25em;\n font-family: inherit;\n background: transparent;\n}\n\n.save-rename-input {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(100, 181, 246, 0.4);\n border-radius: 3px;\n padding: 0.15em 0.4em;\n font: inherit;\n font-size: 0.9em;\n width: 100%;\n}\n\n.save-rename-input:focus {\n outline: none;\n border-color: #64b5f6;\n}\n\n/* Macro: link */\n\n.macro-link {\n color: #64b5f6;\n text-decoration: none;\n border-bottom: 1px solid rgba(100, 181, 246, 0.3);\n transition:\n color 0.2s,\n border-color 0.2s;\n cursor: pointer;\n}\n\n.macro-link:hover {\n color: #90caf9;\n border-bottom-color: #90caf9;\n}\n\n.macro-link:active {\n color: #42a5f5;\n}\n\n/* Macro: form inputs */\n\n.macro-textbox,\n.macro-numberbox,\n.macro-textarea {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n padding: 0.3em 0.5em;\n font: inherit;\n}\n\n.macro-textbox:focus,\n.macro-numberbox:focus,\n.macro-textarea:focus {\n outline: none;\n border-color: #64b5f6;\n}\n\n.macro-textarea {\n min-height: 3em;\n resize: vertical;\n}\n\n.macro-checkbox,\n.macro-radiobutton {\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n gap: 0.3em;\n}\n\n.macro-checkbox input,\n.macro-radiobutton input {\n accent-color: #64b5f6;\n}\n\n/* Macro: listbox */\n\n.macro-listbox {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n padding: 0.3em 0.5em;\n font: inherit;\n}\n\n/* Macro: cycle */\n\n.macro-cycle {\n background: rgba(100, 181, 246, 0.15);\n color: #64b5f6;\n border: 1px solid rgba(100, 181, 246, 0.4);\n border-radius: 4px;\n padding: 0.25em 0.75em;\n font: inherit;\n cursor: pointer;\n transition:\n background 0.2s,\n border-color 0.2s;\n}\n\n.macro-cycle:hover {\n background: rgba(100, 181, 246, 0.25);\n border-color: #90caf9;\n}\n\n/* Macro: meter */\n\n.macro-meter {\n position: relative;\n width: 100%;\n height: 1.4em;\n background: #2a2a3e;\n border-radius: 0.3em;\n overflow: hidden;\n border: 1px solid #444;\n}\n\n.macro-meter-fill {\n height: 100%;\n background: #64b5f6;\n transition: width 0.3s ease;\n}\n\n.macro-meter-label {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 0.85em;\n color: #e0e0e0;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);\n pointer-events: none;\n}\n\n/* Markdown wrapper — invisible to layout */\n\n.md {\n display: contents;\n}\n\n/* Markdown block elements inside passages */\n\n.passage p {\n margin: 0.6em 0;\n}\n\n.passage p:first-child {\n margin-top: 0;\n}\n\n.passage p:last-child {\n margin-bottom: 0;\n}\n\n.passage h1,\n.passage h2,\n.passage h3,\n.passage h4,\n.passage h5,\n.passage h6 {\n margin: 1em 0 0.4em;\n line-height: 1.3;\n color: #f0f0f0;\n}\n\n.passage h1 {\n font-size: 1.8em;\n}\n.passage h2 {\n font-size: 1.5em;\n}\n.passage h3 {\n font-size: 1.25em;\n}\n.passage h4 {\n font-size: 1.1em;\n}\n.passage h5 {\n font-size: 1em;\n}\n.passage h6 {\n font-size: 0.9em;\n opacity: 0.8;\n}\n\n.passage code {\n font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;\n font-size: 0.9em;\n background: rgba(255, 255, 255, 0.08);\n padding: 0.15em 0.35em;\n border-radius: 3px;\n}\n\n.passage pre {\n background: rgba(0, 0, 0, 0.3);\n border: 1px solid rgba(255, 255, 255, 0.08);\n border-radius: 6px;\n padding: 1em;\n overflow-x: auto;\n margin: 0.8em 0;\n}\n\n.passage pre code {\n background: none;\n padding: 0;\n border-radius: 0;\n font-size: 0.85em;\n}\n\n.passage blockquote {\n border-left: 3px solid rgba(100, 181, 246, 0.5);\n margin: 0.8em 0;\n padding: 0.4em 1em;\n color: #b0b0b0;\n}\n\n.passage blockquote p {\n margin: 0.3em 0;\n}\n\n.passage ul,\n.passage ol {\n margin: 0.6em 0;\n padding-left: 1.8em;\n}\n\n.passage li {\n margin: 0.2em 0;\n}\n\n.passage hr {\n border: none;\n border-top: 1px solid rgba(255, 255, 255, 0.12);\n margin: 1.2em 0;\n}\n\n.passage table {\n border-collapse: collapse;\n width: 100%;\n margin: 0.8em 0;\n}\n\n.passage th,\n.passage td {\n border: 1px solid rgba(255, 255, 255, 0.12);\n padding: 0.5em 0.75em;\n text-align: left;\n}\n\n.passage th {\n background: rgba(255, 255, 255, 0.06);\n font-weight: 600;\n}\n\n.passage tr:nth-child(even) {\n background: rgba(255, 255, 255, 0.02);\n}\n\n.passage del {\n opacity: 0.5;\n}\n\n.passage strong {\n color: #f0f0f0;\n}\n\n/* Macro: type (typewriter) */\n\n.macro-type-cursor::after {\n content: '\\2588';\n animation: type-blink 0.7s step-end infinite;\n color: #64b5f6;\n}\n\n.macro-type-done .macro-type-cursor::after {\n display: none;\n}\n\n@keyframes type-blink {\n 50% {\n opacity: 0;\n }\n}\n";
20729
20847
  //#endregion
20730
20848
  //#region src/index.tsx
20731
20849
  function renderErrors(root, errors) {
@@ -20753,6 +20871,15 @@ function stopWithErrors(errors) {
20753
20871
  throw new Error(`spindle: ${errors.length} validation error(s):\n${errors.join("\n")}`);
20754
20872
  }
20755
20873
  /**
20874
+ * The undeclared variables the passages refer to, with the macros known now:
20875
+ * the input macros that bind a variable (registered by author JS or
20876
+ * StoryInit) and the roles of their parameters decide what is a reference.
20877
+ */
20878
+ function variableErrors(storyData, schema) {
20879
+ const storeVarMacros = getMacroRegistry().filter((m) => m.storeVar).map((m) => m.name);
20880
+ return validatePassages(storyData.passages, schema, storeVarMacros);
20881
+ }
20882
+ /**
20756
20883
  * The markup errors (malformed markup, unknown macros, syntax errors in
20757
20884
  * code) of the passages for which `only` holds, with the macros and widgets
20758
20885
  * known now.
@@ -20801,9 +20928,8 @@ function boot() {
20801
20928
  throw new Error(`spindle: ${msg}`);
20802
20929
  }
20803
20930
  const schema = parseStoryVariables(storyVarsPassage.content);
20804
- const storeVarMacros = getMacroRegistry().filter((m) => m.storeVar).map((m) => m.name);
20805
20931
  for (const name of blockWidgetNames(storyData.passages.values())) registerBlockMacro(name);
20806
- const errors = validatePassages(storyData.passages, schema, storeVarMacros);
20932
+ const errors = variableErrors(storyData, schema);
20807
20933
  errors.push(...markupErrors(storyData, (p) => p.name === "StoryInit"));
20808
20934
  let transientDefaults = {};
20809
20935
  const storyTransientsPassage = storyData.passages.get("StoryTransients");
@@ -20818,7 +20944,7 @@ function boot() {
20818
20944
  useStoryStore.getState().init(storyData, defaults, transientDefaults);
20819
20945
  enterRuntimePhase();
20820
20946
  initializeStory(loadSession(storyData.ifid));
20821
- const markup = markupErrors(storyData, (p) => p.name !== "StoryInit");
20947
+ const markup = [...variableErrors(storyData, schema), ...markupErrors(storyData, (p) => p.name !== "StoryInit")];
20822
20948
  if (markup.length > 0) stopWithErrors(markup);
20823
20949
  for (const [, passage] of storyData.passages) if (passage.tags.includes("widget")) {
20824
20950
  const widgetAST = parseMarkup(passage.content);