@rohal12/spindle 0.59.7 → 0.59.9

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
  /**
@@ -11143,7 +11143,7 @@ function useRunOnce(macro, rawArgs, effect) {
11143
11143
  effect();
11144
11144
  } catch (error) {
11145
11145
  failure.current = { error };
11146
- logMacroError(`${macro} ${rawArgs}`, error, location);
11146
+ logMacroError(`${macro} ${rawArgs}`.trim(), error, location);
11147
11147
  }
11148
11148
  }
11149
11149
  if (!failure.current) return null;
@@ -12548,6 +12548,7 @@ var CLASS_PREFIX = "c:";
12548
12548
  var ERROR_PREFIX = "E:";
12549
12549
  var SYMBOL_TAG = "S";
12550
12550
  var KEYS_TAG = "K";
12551
+ var REGEXP_TAG = "R";
12551
12552
  var BUILTIN_ERRORS = new Map([
12552
12553
  Error,
12553
12554
  EvalError,
@@ -12609,6 +12610,11 @@ function reducers() {
12609
12610
  const key = Symbol.keyFor(v);
12610
12611
  return key !== void 0 && [key];
12611
12612
  };
12613
+ out[REGEXP_TAG] = (v) => v instanceof RegExp && v.lastIndex !== 0 && [
12614
+ v.source,
12615
+ v.flags,
12616
+ v.lastIndex
12617
+ ];
12612
12618
  out[KEYS_TAG] = (v) => {
12613
12619
  if (!isObject(v) || Object.getPrototypeOf(v) !== Object.prototype) return false;
12614
12620
  const keys = Object.keys(v);
@@ -12682,6 +12688,12 @@ function reviverFor(key) {
12682
12688
  return Symbol.for(data[0]);
12683
12689
  };
12684
12690
  if (key === KEYS_TAG) return reviveKeyed;
12691
+ if (key === REGEXP_TAG) return (data) => {
12692
+ if (!Array.isArray(data) || typeof data[0] !== "string" || typeof data[1] !== "string" || !Number.isSafeInteger(data[2])) throw new TypeError("spindle: Malformed regular expression");
12693
+ const re = new RegExp(data[0], data[1]);
12694
+ re.lastIndex = data[2];
12695
+ return re;
12696
+ };
12685
12697
  if (key.startsWith(CLASS_PREFIX)) return reviveClass(key.slice(2));
12686
12698
  if (key.startsWith(ERROR_PREFIX)) return reviveError(key.slice(2));
12687
12699
  }
@@ -12793,6 +12805,7 @@ function isOptionalPRNGSnapshot(value) {
12793
12805
  return isRecord$1(value) && typeof value.seed === "string" && typeof value.pull === "number" && Number.isInteger(value.pull) && value.pull >= 0;
12794
12806
  }
12795
12807
  var isMoment = (value) => isRecord$1(value) && typeof value.passage === "string" && isRecord$1(value.variables) && typeof value.timestamp === "number" && isOptionalPRNGSnapshot(value.prng);
12808
+ var isWatchers = (value) => value === void 0 || Array.isArray(value) && Array.from(value).every((w) => isRecord$1(w) && typeof w.condition === "string" && isRecord$1(w.options));
12796
12809
  var isCountsMap = (value) => value === void 0 || value instanceof Map && [...value.keys()].every((k) => typeof k === "string");
12797
12810
  /**
12798
12811
  * Whether a deserialized payload is well formed: every history moment is,
@@ -12806,7 +12819,7 @@ function isPayload(value) {
12806
12819
  if (!Array.isArray(history) || history.length === 0) return false;
12807
12820
  if (!Array.from(history).every(isMoment)) return false;
12808
12821
  if (typeof historyIndex !== "number" || !Number.isInteger(historyIndex) || historyIndex < 0 || historyIndex >= history.length || history[historyIndex].passage !== value.passage) return false;
12809
- return isCountsMap(value.visitCounts) && isCountsMap(value.renderCounts) && isOptionalPRNGSnapshot(value.prng);
12822
+ return isCountsMap(value.visitCounts) && isCountsMap(value.renderCounts) && isOptionalPRNGSnapshot(value.prng) && isWatchers(value.watchers);
12810
12823
  }
12811
12824
  var mapToCounts = (counts) => counts instanceof Map ? createCounts(Object.fromEntries(counts)) : void 0;
12812
12825
  /**
@@ -13176,24 +13189,28 @@ function createIDBBackend() {
13176
13189
  }
13177
13190
  var _backend = null;
13178
13191
  async function detectBackend() {
13179
- if (typeof indexedDB !== "undefined") try {
13180
- await new Promise((resolve, reject) => {
13181
- const req = indexedDB.open("__spindle_probe__", 1);
13182
- req.onsuccess = () => {
13183
- req.result.close();
13184
- indexedDB.deleteDatabase("__spindle_probe__");
13185
- resolve();
13186
- };
13187
- req.onerror = () => reject(req.error);
13188
- });
13189
- return createIDBBackend();
13192
+ try {
13193
+ if (typeof indexedDB !== "undefined") {
13194
+ await new Promise((resolve, reject) => {
13195
+ const req = indexedDB.open("__spindle_probe__", 1);
13196
+ req.onsuccess = () => {
13197
+ req.result.close();
13198
+ indexedDB.deleteDatabase("__spindle_probe__");
13199
+ resolve();
13200
+ };
13201
+ req.onerror = () => reject(req.error);
13202
+ });
13203
+ return createIDBBackend();
13204
+ }
13190
13205
  } catch {}
13191
- if (typeof localStorage !== "undefined") try {
13192
- const probeKey = "__spindle_probe__";
13193
- localStorage.setItem(probeKey, "1");
13194
- const val = localStorage.getItem(probeKey);
13195
- localStorage.removeItem(probeKey);
13196
- if (val === "1") return createLocalStorageBackend();
13206
+ try {
13207
+ if (typeof localStorage !== "undefined") {
13208
+ const probeKey = "__spindle_probe__";
13209
+ localStorage.setItem(probeKey, "1");
13210
+ const val = localStorage.getItem(probeKey);
13211
+ localStorage.removeItem(probeKey);
13212
+ if (val === "1") return createLocalStorageBackend();
13213
+ }
13197
13214
  } catch {}
13198
13215
  return createMemoryBackend();
13199
13216
  }
@@ -13402,7 +13419,11 @@ function deepClone(value, options = {}) {
13402
13419
  }
13403
13420
  function cloneBuiltin(val) {
13404
13421
  if (val instanceof Date) return keep(val, new Date(val.getTime()));
13405
- if (val instanceof RegExp) return keep(val, new RegExp(val.source, val.flags));
13422
+ if (val instanceof RegExp) {
13423
+ const copy = new RegExp(val.source, val.flags);
13424
+ copy.lastIndex = val.lastIndex;
13425
+ return keep(val, copy);
13426
+ }
13406
13427
  if (val instanceof ArrayBuffer) return keep(val, val.slice(0));
13407
13428
  if (ArrayBuffer.isView(val)) {
13408
13429
  const buffer = clone(val.buffer);
@@ -13585,7 +13606,7 @@ function equalBytes(a, b) {
13585
13606
  /** Equality of the content of atomic built-ins (see isAtomic) but Map/Set. */
13586
13607
  function equalBuiltin(a, b, assumed) {
13587
13608
  if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
13588
- if (a instanceof RegExp) return String(a) === String(b);
13609
+ if (a instanceof RegExp) return String(a) === String(b) && a.lastIndex === b.lastIndex;
13589
13610
  if (ArrayBuffer.isView(a)) {
13590
13611
  const v = b;
13591
13612
  return a.byteOffset === v.byteOffset && a.byteLength === v.byteLength && equal(a.buffer, v.buffer, assumed);
@@ -14144,15 +14165,17 @@ var readingSlot = (read) => queued(async (ifid, slot) => read(await slotRecord(i
14144
14165
  var startNewPlaythrough = queued(startNewPlaythroughNow);
14145
14166
  async function startNewPlaythroughNow(ifid, id = randomUUID()) {
14146
14167
  const backend = await getBackend();
14147
- const num = await nextPlaythroughNumber(ifid);
14148
- const record = {
14149
- id,
14150
- ifid,
14151
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
14152
- label: `Playthrough ${num}`
14153
- };
14154
- await backend.putPlaythrough(record);
14155
- await backend.setMeta(playthroughCountKey(ifid), num);
14168
+ await withTabLock(`spindle-playthrough-count:${ifid}`, async () => {
14169
+ const num = await nextPlaythroughNumber(ifid);
14170
+ const record = {
14171
+ id,
14172
+ ifid,
14173
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
14174
+ label: `Playthrough ${num}`
14175
+ };
14176
+ await backend.putPlaythrough(record);
14177
+ await backend.setMeta(playthroughCountKey(ifid), num);
14178
+ });
14156
14179
  await backend.setMeta(currentPlaythroughKey(ifid), id);
14157
14180
  return id;
14158
14181
  }
@@ -14405,12 +14428,41 @@ function slotMetaKey(ifid, slot) {
14405
14428
  return isNamedSlot(slot) ? `${SLOT_KEY_PREFIX}${slot}.${ifid}` : `${AUTOSAVE_KEY_PREFIX}${ifid}`;
14406
14429
  }
14407
14430
  var slotIndexKey = (ifid) => `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
14408
- /** Read-modify-write the per-story slot index. */
14431
+ /**
14432
+ * Run `op` holding the browser's lock `name`, so other tabs of the story
14433
+ * (the operation queue only orders this document's operations) wait for it.
14434
+ * Runs `op` without a lock where the browser offers none (or refuses it, as
14435
+ * in a sandboxed frame, which has no other tabs sharing its storage).
14436
+ */
14437
+ async function withTabLock(name, op) {
14438
+ let locks;
14439
+ try {
14440
+ locks = navigator.locks;
14441
+ } catch {}
14442
+ if (!locks) return op();
14443
+ let started = false;
14444
+ try {
14445
+ return await locks.request(name, () => {
14446
+ started = true;
14447
+ return op();
14448
+ });
14449
+ } catch (err) {
14450
+ if (started) throw err;
14451
+ return op();
14452
+ }
14453
+ }
14454
+ /**
14455
+ * Read-modify-write the per-story slot index. Under a lock shared by the
14456
+ * story's tabs: two tabs adding a slot at once would both read the old index
14457
+ * and the later write would drop the other's slot.
14458
+ */
14409
14459
  async function updateSlotIndex(ifid, update) {
14410
14460
  const backend = await getBackend();
14411
- const existing = await backend.getMeta(slotIndexKey(ifid)) ?? [];
14412
- const updated = update(existing);
14413
- if (updated !== existing) await backend.setMeta(slotIndexKey(ifid), updated);
14461
+ await withTabLock(`spindle-slot-index:${ifid}`, async () => {
14462
+ const existing = await backend.getMeta(slotIndexKey(ifid)) ?? [];
14463
+ const updated = update(existing);
14464
+ if (updated !== existing) await backend.setMeta(slotIndexKey(ifid), updated);
14465
+ });
14414
14466
  }
14415
14467
  /** Every slot: the default one (undefined), then those in the slot index. */
14416
14468
  async function allSlots(ifid) {
@@ -14463,7 +14515,7 @@ function toSaveInfo(record, slot) {
14463
14515
  * (settled by operations issued before this call), so the save takes its
14464
14516
  * place in the order of operations when it is called.
14465
14517
  */
14466
- function quickSave(ifid, playthroughId, payload, slot, custom) {
14518
+ function quickSave$1(ifid, playthroughId, payload, slot, custom) {
14467
14519
  return inOrder(async () => {
14468
14520
  const ptId = await playthroughId;
14469
14521
  const existingId = await slotSaveId(ifid, slot);
@@ -14549,12 +14601,18 @@ var SESSION_KEY_PREFIX = "spindle.session.";
14549
14601
  * encodePayload). Throws, like a save, when the state holds a value a save
14550
14602
  * cannot (a function, an instance of an unregistered class, a unique
14551
14603
  * symbol); the session then keeps its previous copy.
14604
+ *
14605
+ * A storage that is unavailable or full is returned, not thrown: the session
14606
+ * keeps its previous copy then too, but the story can go on, so the caller
14607
+ * decides how to tell the player.
14552
14608
  */
14553
14609
  function saveSession(ifid, payload) {
14554
14610
  const text = JSON.stringify(encodePayload(payload));
14555
14611
  try {
14556
14612
  sessionStorage.setItem(`${SESSION_KEY_PREFIX}${ifid}`, text);
14557
- } catch {}
14613
+ } catch (error) {
14614
+ return { error };
14615
+ }
14558
14616
  }
14559
14617
  /**
14560
14618
  * The live payload of the stored session, if there is one that loads. A
@@ -14671,14 +14729,14 @@ var clearAllData = queued(clearAllDataNow);
14671
14729
  async function clearAllDataNow() {
14672
14730
  await (await getBackend()).destroy();
14673
14731
  resetBackend();
14674
- if (typeof sessionStorage !== "undefined") {
14732
+ try {
14675
14733
  const toRemove = [];
14676
14734
  for (let i = 0; i < sessionStorage.length; i++) {
14677
14735
  const key = sessionStorage.key(i);
14678
14736
  if (key?.startsWith("spindle.session.")) toRemove.push(key);
14679
14737
  }
14680
14738
  for (const key of toRemove) sessionStorage.removeItem(key);
14681
- }
14739
+ } catch {}
14682
14740
  }
14683
14741
  /**
14684
14742
  * Delete a playthrough with its saves, emptying the slots that held them.
@@ -14751,6 +14809,11 @@ function executeStoryInit() {
14751
14809
  */
14752
14810
  function initializeStory(session) {
14753
14811
  executeStoryInit();
14812
+ const missing = session ? missingPassage(useStoryStore.getState().storyData, session) : void 0;
14813
+ if (missing !== void 0) {
14814
+ console.warn(`spindle: discarding the saved session: the story no longer has the passage "${missing}"`);
14815
+ session = void 0;
14816
+ }
14754
14817
  if (session) useStoryStore.getState().loadFromPayload(session);
14755
14818
  emit("storyinit");
14756
14819
  if (!session) recordStoryInitState();
@@ -15244,6 +15307,27 @@ function addMacroTrigger(condition, options) {
15244
15307
  if (triggers.some((t) => t.macroKey === macroKey)) return;
15245
15308
  registerTrigger(condition, options, macroKey);
15246
15309
  }
15310
+ /**
15311
+ * The {watch} macro watchers now registered, for a save or the session:
15312
+ * watchers outlive the passage that declared them, so a game restored
15313
+ * where it stopped needs the ones its earlier passages registered (and
15314
+ * not the ones {unwatch} or `once` removed).
15315
+ */
15316
+ function savedMacroWatchers() {
15317
+ return triggers.filter((t) => t.macroKey !== void 0 && t.options).map((t) => ({
15318
+ condition: t.condition,
15319
+ options: { ...t.options }
15320
+ }));
15321
+ }
15322
+ /**
15323
+ * Make the {watch} macro watchers those of a loaded game: the ones of
15324
+ * `watchers` replace the registered ones (watchers added by Story.watch
15325
+ * code stay). The passage shown mounts its own {watch} macros again.
15326
+ */
15327
+ function restoreMacroWatchers(watchers) {
15328
+ triggers = triggers.filter((t) => t.macroKey === void 0);
15329
+ for (const { condition, options } of watchers) addMacroTrigger(condition, options);
15330
+ }
15247
15331
  function removeTrigger(name) {
15248
15332
  triggers = triggers.filter((t) => t.name !== name);
15249
15333
  }
@@ -15584,7 +15668,7 @@ var sessionMoments = [];
15584
15668
  */
15585
15669
  function persistSession(get) {
15586
15670
  const { storyData, currentPassage, variables, history, historyIndex, visitCounts, renderCounts } = get();
15587
- if (!storyData) return;
15671
+ if (!storyData) return void 0;
15588
15672
  if (sessionMoments.length > history.length) sessionMoments.length = history.length;
15589
15673
  if (sessionMoments.length < history.length) if (history.length - sessionMoments.length === 1) {
15590
15674
  const i = history.length - 1;
@@ -15592,14 +15676,15 @@ function persistSession(get) {
15592
15676
  } else forEachRecorded(history.length, (vars, i) => {
15593
15677
  if (i >= sessionMoments.length) sessionMoments[i] = savedMoment(history[i], vars);
15594
15678
  });
15595
- saveSession(storyData.ifid, {
15679
+ return saveSession(storyData.ifid, {
15596
15680
  passage: currentPassage,
15597
15681
  variables,
15598
15682
  history: sessionMoments,
15599
15683
  historyIndex,
15600
15684
  visitCounts,
15601
15685
  renderCounts,
15602
- prng: snapshotPRNG()
15686
+ prng: snapshotPRNG(),
15687
+ watchers: savedMacroWatchers()
15603
15688
  });
15604
15689
  }
15605
15690
  /** What the page shows before a session write error (see RuntimeErrors). */
@@ -15618,7 +15703,8 @@ var NAVIGATION_ERROR_CONTEXT = "The story could not go to another passage:";
15618
15703
  function persistSessionThen(get, after) {
15619
15704
  let failure;
15620
15705
  try {
15621
- persistSession(get);
15706
+ const storageFailure = persistSession(get);
15707
+ if (storageFailure) showRuntimeError(SESSION_ERROR_CONTEXT, storageFailure.error);
15622
15708
  } catch (error) {
15623
15709
  failure = { error };
15624
15710
  showRuntimeError(SESSION_ERROR_CONTEXT, error);
@@ -15983,6 +16069,15 @@ function restorePRNGFrom(prng) {
15983
16069
  function restorePRNGFromMoment(moment) {
15984
16070
  if (moment) restorePRNGFrom(moment.prng);
15985
16071
  }
16072
+ /**
16073
+ * The first passage of `payload` (its current passage, then its history)
16074
+ * that `storyData` does not have, as when the story was updated since the
16075
+ * save; undefined if the story has them all (or is not loaded).
16076
+ */
16077
+ function missingPassage(storyData, payload) {
16078
+ if (!storyData) return void 0;
16079
+ return [payload.passage, ...payload.history.map((m) => m.passage)].find((name) => !storyData.passages.has(name));
16080
+ }
15986
16081
  var NAMESPACE_KEYS = [
15987
16082
  "variables",
15988
16083
  "temporary",
@@ -16312,7 +16407,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
16312
16407
  set((state) => {
16313
16408
  state.saveError = null;
16314
16409
  });
16315
- await quickSave(storyData.ifid, playthrough, payload, slot, custom);
16410
+ await quickSave$1(storyData.ifid, playthrough, payload, slot, custom);
16316
16411
  recordKnownSave(set, slot, true);
16317
16412
  }), "spindle: failed to save", (err) => set((state) => {
16318
16413
  state.saveError = errorMessage(err, "Failed to save");
@@ -16410,7 +16505,8 @@ var useStoryStore = create()(storyStore((set, get) => ({
16410
16505
  historyIndex,
16411
16506
  visitCounts: { ...visitCounts },
16412
16507
  renderCounts: { ...renderCounts },
16413
- prng: snapshotPRNG()
16508
+ prng: snapshotPRNG(),
16509
+ watchers: savedMacroWatchers()
16414
16510
  };
16415
16511
  },
16416
16512
  beginSave: () => {
@@ -16422,12 +16518,15 @@ var useStoryStore = create()(storyStore((set, get) => ({
16422
16518
  };
16423
16519
  },
16424
16520
  loadFromPayload: (payload, slot, playthroughId) => {
16425
- const replacement = slotLoadApplying ?? ++stateReplacementsIssued;
16521
+ const applying = slotLoadApplying;
16426
16522
  slotLoadApplying = null;
16523
+ const replacement = applying ?? ++stateReplacementsIssued;
16427
16524
  if (payload.history.length === 0) {
16428
16525
  console.warn("loadFromPayload: rejecting payload with empty history");
16429
16526
  return;
16430
16527
  }
16528
+ const missing = missingPassage(get().storyData, payload);
16529
+ if (missing !== void 0) throw new Error(`The save refers to the passage "${missing}", which this version of the story does not have`);
16431
16530
  latestStateApplied = replacement;
16432
16531
  emit("beforeload", slot);
16433
16532
  const ifid = get().storyData?.ifid;
@@ -16453,6 +16552,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
16453
16552
  state.temporary = createNamespace();
16454
16553
  state.transient = createNamespace(deepClone(get().transientDefaults));
16455
16554
  });
16555
+ if (payload.watchers) restoreMacroWatchers(payload.watchers);
16456
16556
  reinitTriggerState();
16457
16557
  lastNavigationVars = reconstructVarsAt(get().historyIndex);
16458
16558
  restorePRNGFrom(entry?.prng !== void 0 ? entry.prng : payload.prng);
@@ -16525,8 +16625,19 @@ var FOCUSABLE = [
16525
16625
  "[contenteditable=\"true\"]",
16526
16626
  "[tabindex]:not([tabindex=\"-1\"])"
16527
16627
  ].join(",");
16628
+ /**
16629
+ * Whether CSS leaves `el` in the tab sequence: not `visibility: hidden`
16630
+ * (inherited, so the computed value covers ancestors) and not inside a
16631
+ * `display: none` element.
16632
+ */
16633
+ function isRendered(el) {
16634
+ const { visibility } = getComputedStyle(el);
16635
+ if (visibility === "hidden" || visibility === "collapse") return false;
16636
+ for (let e = el; e; e = e.parentElement) if (getComputedStyle(e).display === "none") return false;
16637
+ return true;
16638
+ }
16528
16639
  function focusables(root) {
16529
- return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el) => !el.hidden && !el.closest("[hidden], [inert]"));
16640
+ return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el) => !el.hidden && !el.closest("[hidden], [inert]") && isRendered(el));
16530
16641
  }
16531
16642
  /** Open modal dialogs, innermost last. Only the topmost handles keys. */
16532
16643
  var openModals = [];
@@ -16624,7 +16735,7 @@ function PassageDialog({ passageName, fallbackMarkup, panelClass, onClose, dismi
16624
16735
  }, []);
16625
16736
  const handleBackdrop = (e) => {
16626
16737
  if (!dismissible) return;
16627
- if (e.target.classList.contains("dialog-overlay")) stableOnClose();
16738
+ if (e.target === e.currentTarget) stableOnClose();
16628
16739
  };
16629
16740
  const cls = panelClass ? `dialog-panel ${panelClass}` : "dialog-panel";
16630
16741
  return /* @__PURE__ */ jsx(DialogCloseContext.Provider, {
@@ -16639,6 +16750,7 @@ function PassageDialog({ passageName, fallbackMarkup, panelClass, onClose, dismi
16639
16750
  "aria-modal": "true",
16640
16751
  tabIndex: -1,
16641
16752
  children: [showCloseButton && /* @__PURE__ */ jsx("button", {
16753
+ type: "button",
16642
16754
  class: "dialog-close",
16643
16755
  "aria-label": "Close",
16644
16756
  onClick: stableOnClose,
@@ -16742,18 +16854,39 @@ function RuntimeErrors() {
16742
16854
  });
16743
16855
  }
16744
16856
  //#endregion
16857
+ //#region src/quick-actions.ts
16858
+ /**
16859
+ * QuickSave and QuickLoad as the built-in buttons and hotkeys perform them:
16860
+ * a failure is shown on the page (the Story API's save() and load() only
16861
+ * reject, for the code that awaits them).
16862
+ */
16863
+ async function quickSave() {
16864
+ try {
16865
+ await useStoryStore.getState().save();
16866
+ } catch (error) {
16867
+ showRuntimeError("The game could not be saved:", error);
16868
+ }
16869
+ }
16870
+ async function quickLoad() {
16871
+ try {
16872
+ await useStoryStore.getState().load();
16873
+ } catch (error) {
16874
+ showRuntimeError("The saved game could not be loaded:", error);
16875
+ }
16876
+ }
16877
+ //#endregion
16745
16878
  //#region src/components/App.tsx
16746
16879
  function App() {
16747
16880
  const { storyData, currentPassage, nobr } = useStoryFields("storyData", "currentPassage", "nobr");
16748
16881
  useEffect(() => {
16749
16882
  const onKeyDown = (e) => {
16750
- const { quickSaveKey, quickLoadKey, save, load } = useStoryStore.getState();
16883
+ const { quickSaveKey, quickLoadKey } = useStoryStore.getState();
16751
16884
  if (quickSaveKey !== null && e.key === quickSaveKey) {
16752
16885
  e.preventDefault();
16753
- save();
16886
+ quickSave();
16754
16887
  } else if (quickLoadKey !== null && e.key === quickLoadKey) {
16755
16888
  e.preventDefault();
16756
- load();
16889
+ quickLoad();
16757
16890
  }
16758
16891
  };
16759
16892
  document.addEventListener("keydown", onKeyDown);
@@ -16931,7 +17064,9 @@ function storageKey() {
16931
17064
  return `spindle.${useStoryStore.getState().storyData?.ifid || "unknown"}.settings`;
16932
17065
  }
16933
17066
  function persist() {
16934
- localStorage.setItem(storageKey(), JSON.stringify(values));
17067
+ try {
17068
+ localStorage.setItem(storageKey(), JSON.stringify(values));
17069
+ } catch {}
16935
17070
  }
16936
17071
  function loadFromStorage() {
16937
17072
  if (storageLoaded) return;
@@ -18215,7 +18350,13 @@ function widgetDef({ name = "", parameters = "" }) {
18215
18350
  function parseWidgetDef(rawArgs) {
18216
18351
  return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
18217
18352
  }
18218
- var BLOCK_WIDGET = /\{widget\s+["']?(\w+)["']?[^}]*\}([\s\S]*?)\{\/widget\}/g;
18353
+ /**
18354
+ * A definition: its arguments (read like a {widget} macro's, see
18355
+ * parseWidgetDef) and its body. Macro names are not case-sensitive.
18356
+ */
18357
+ var WIDGET_DEFINITION = /\{widget\s+([^}]*)\}([\s\S]*?)\{\/widget\}/gi;
18358
+ /** `{@children}`, also with selectors such as `{.highlight @children}`. */
18359
+ var CHILDREN_PLACEHOLDER = /\{[^{}]*@children\s*\}/;
18219
18360
  /**
18220
18361
  * The names of the block widgets (those whose body renders `{@children}`)
18221
18362
  * that StoryInit and the passages tagged `widget` define. They must be
@@ -18226,7 +18367,12 @@ function blockWidgetNames(passages) {
18226
18367
  const names = [];
18227
18368
  for (const passage of passages) {
18228
18369
  if (passage.name !== "StoryInit" && !passage.tags?.includes("widget")) continue;
18229
- for (const match of passage.content.matchAll(BLOCK_WIDGET)) if (/\{@children\}/.test(match[2])) names.push(match[1]);
18370
+ for (const match of passage.content.matchAll(WIDGET_DEFINITION)) {
18371
+ if (!CHILDREN_PLACEHOLDER.test(match[2])) continue;
18372
+ try {
18373
+ names.push(parseWidgetDef(match[1]).name);
18374
+ } catch {}
18375
+ }
18230
18376
  }
18231
18377
  return names;
18232
18378
  }
@@ -19030,7 +19176,7 @@ function keyOf(val, ancestors) {
19030
19176
  const depth = ancestors.indexOf(obj);
19031
19177
  if (depth !== -1) return `<cycle ${ancestors.length - depth}>`;
19032
19178
  if (val instanceof Date) return `Date(${val.getTime()})`;
19033
- if (val instanceof RegExp) return `RegExp(${String(val)})`;
19179
+ if (val instanceof RegExp) return `RegExp(${String(val)}@${val.lastIndex})`;
19034
19180
  ancestors.push(obj);
19035
19181
  try {
19036
19182
  if (Array.isArray(val)) return `[${Array.from(val, (v) => keyOf(v, ancestors)).join(",")}]`;
@@ -19145,15 +19291,7 @@ defineMacro({
19145
19291
  block: true,
19146
19292
  render({ children = [] }, ctx) {
19147
19293
  const code = ctx.collectText(children);
19148
- ctx.hooks.useLayoutEffect(() => {
19149
- const location = ctx.sourceLocation();
19150
- try {
19151
- ctx.mutate(code);
19152
- } catch (err) {
19153
- logMacroError("do", err, location);
19154
- }
19155
- }, []);
19156
- return null;
19294
+ return useRunOnce("do", "", () => ctx.mutate(code));
19157
19295
  }
19158
19296
  });
19159
19297
  //#endregion
@@ -19220,7 +19358,10 @@ defineMacro({
19220
19358
  * Return a function that runs a macro body once, outside the passage tree:
19221
19359
  * it renders `children` into a detached node, so the body's macros ({set},
19222
19360
  * {if}, {goto}, ...) fire their side effects through the normal Preact
19223
- * pipeline, then unmounts it. Used by {button} and {link} on click.
19361
+ * pipeline. Used by {button} and {link} on click. The body stays mounted
19362
+ * until the owning macro unmounts (its passage changes, say), so work it
19363
+ * starts in an effect, such as the timer of a {timed} or {repeat}, can
19364
+ * finish; the passage leaving cancels it.
19224
19365
  *
19225
19366
  * A detached render starts with no contexts, so the hook captures the ones
19226
19367
  * the owning macro sees and provides them again: the locals scope (read
@@ -19241,6 +19382,10 @@ function useDetachedBody() {
19241
19382
  const widgetChildren = useContext(WidgetChildrenContext);
19242
19383
  const repeat = useContext(RepeatContext);
19243
19384
  const closeDialog = useContext(DialogCloseContext);
19385
+ const mounted = useRef([]);
19386
+ useEffect(() => () => {
19387
+ for (const container of mounted.current.splice(0)) render(null, container);
19388
+ }, []);
19244
19389
  const run = (children) => {
19245
19390
  const locals = liveLocalsView(updater.getValues);
19246
19391
  const container = document.createElement("div");
@@ -19270,7 +19415,7 @@ function useDetachedBody() {
19270
19415
  })
19271
19416
  })
19272
19417
  }), container);
19273
- render(null, container);
19418
+ mounted.current.push(container);
19274
19419
  };
19275
19420
  return (children) => runWithCommittedMutations(() => run(children));
19276
19421
  }
@@ -19298,6 +19443,7 @@ defineMacro({
19298
19443
  perform: handleClick
19299
19444
  });
19300
19445
  return /* @__PURE__ */ jsx("button", {
19446
+ type: "button",
19301
19447
  id: ctx.id,
19302
19448
  class: ctx.cls,
19303
19449
  onClick: handleClick,
@@ -19454,15 +19600,52 @@ defineMacro({
19454
19600
  });
19455
19601
  //#endregion
19456
19602
  //#region src/components/macros/Type.tsx
19603
+ /**
19604
+ * The clip-path that shows the first `count` characters of `root`'s text in
19605
+ * reading order: the boxes the browser laid out for them, each wrapped line
19606
+ * as far as its last revealed character, as one path in the coordinates of
19607
+ * `inner`'s box. Undefined where there is no layout to measure.
19608
+ */
19609
+ function revealedPath(root, inner, count) {
19610
+ const range = document.createRange();
19611
+ if (!range.getClientRects) return void 0;
19612
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
19613
+ let left = count;
19614
+ let end;
19615
+ for (let n = walker.nextNode(); n && left > 0; n = walker.nextNode()) {
19616
+ const length = n.nodeValue?.length ?? 0;
19617
+ end = {
19618
+ node: n,
19619
+ offset: Math.min(left, length)
19620
+ };
19621
+ left -= length;
19622
+ }
19623
+ if (!end) return "inset(0 100% 0 0)";
19624
+ range.setStart(root, 0);
19625
+ range.setEnd(end.node, end.offset);
19626
+ const rects = range.getClientRects();
19627
+ if (rects.length === 0) return void 0;
19628
+ const origin = inner.getBoundingClientRect();
19629
+ let path = "";
19630
+ for (const r of Array.from(rects)) {
19631
+ if (r.width === 0 && r.height === 0) continue;
19632
+ const x = (r.left - origin.left).toFixed(2);
19633
+ const y = (r.top - origin.top).toFixed(2);
19634
+ path += `M${x} ${y}h${r.width.toFixed(2)}v${r.height.toFixed(2)}h-${r.width.toFixed(2)}z`;
19635
+ }
19636
+ return path ? `path("${path}")` : void 0;
19637
+ }
19457
19638
  defineMacro({
19458
19639
  name: "type",
19459
19640
  block: true,
19460
19641
  interpolate: true,
19461
19642
  parameters: [DELAY_PARAMETER],
19462
19643
  render({ children = [] }, ctx) {
19463
- const { useState, useEffect, useRef } = ctx.hooks;
19644
+ const { useState, useEffect, useLayoutEffect, useRef } = ctx.hooks;
19464
19645
  const speed = ctx.args.delay ?? 0;
19465
19646
  const containerRef = useRef(null);
19647
+ const innerRef = useRef(null);
19648
+ const [clip, setClip] = useState();
19466
19649
  const [totalChars, setTotalChars] = useState(0);
19467
19650
  const [visibleChars, setVisibleChars] = useState(0);
19468
19651
  const visibleCharsRef = useRef(0);
@@ -19485,6 +19668,22 @@ defineMacro({
19485
19668
  return () => clearInterval(timer);
19486
19669
  }, [totalChars, speed]);
19487
19670
  const done = visibleChars >= totalChars && totalChars > 0;
19671
+ useLayoutEffect(() => {
19672
+ const root = containerRef.current;
19673
+ const inner = innerRef.current;
19674
+ if (!root || !inner || totalChars === 0 || done) {
19675
+ setClip(void 0);
19676
+ return;
19677
+ }
19678
+ const measure = () => setClip(revealedPath(root, inner, visibleChars));
19679
+ measure();
19680
+ window.addEventListener("resize", measure);
19681
+ return () => window.removeEventListener("resize", measure);
19682
+ }, [
19683
+ visibleChars,
19684
+ totalChars,
19685
+ done
19686
+ ]);
19488
19687
  const cls = [
19489
19688
  "macro-type",
19490
19689
  done ? "macro-type-done" : "",
@@ -19494,13 +19693,13 @@ defineMacro({
19494
19693
  id: ctx.id,
19495
19694
  class: cls,
19496
19695
  ref: containerRef,
19497
- style: { clipPath: totalChars > 0 && !done ? void 0 : void 0 },
19498
19696
  children: [/* @__PURE__ */ jsx("span", {
19697
+ ref: innerRef,
19499
19698
  class: "macro-type-inner",
19500
19699
  style: {
19501
19700
  display: "inline",
19502
19701
  visibility: totalChars === 0 ? "hidden" : "visible",
19503
- clipPath: totalChars > 0 && !done ? `inset(0 ${(totalChars - visibleChars) / totalChars * 100}% 0 0)` : void 0
19702
+ clipPath: clip
19504
19703
  },
19505
19704
  children: ctx.renderInlineNodes(children)
19506
19705
  }), !done && totalChars > 0 && /* @__PURE__ */ jsx("span", { class: "macro-type-cursor" })]
@@ -19548,6 +19747,7 @@ function defineMenubarAction(config) {
19548
19747
  perform: config.dialog ? () => setDialogOpen(true) : setup.perform
19549
19748
  });
19550
19749
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
19750
+ type: "button",
19551
19751
  id: ctx.id,
19552
19752
  class: cls,
19553
19753
  onClick: perform,
@@ -19587,9 +19787,9 @@ defineMenubarAction({
19587
19787
  label: "QuickSave",
19588
19788
  actionType: "save",
19589
19789
  setup: () => {
19590
- const { save, quickSaveKey } = useStoryFields("save", "quickSaveKey");
19790
+ const { quickSaveKey } = useStoryFields("quickSaveKey");
19591
19791
  return {
19592
- perform: save,
19792
+ perform: quickSave,
19593
19793
  title: hotkeyTitle("Quick Save", quickSaveKey)
19594
19794
  };
19595
19795
  }
@@ -19600,9 +19800,9 @@ defineMenubarAction({
19600
19800
  actionType: "load",
19601
19801
  confirm: "Load saved game? Current progress will be lost.",
19602
19802
  setup: () => {
19603
- const { load, hasSave, quickLoadKey } = useStoryFields("load", "hasSave", "quickLoadKey", "knownSaves");
19803
+ const { hasSave, quickLoadKey } = useStoryFields("hasSave", "quickLoadKey", "knownSaves");
19604
19804
  return {
19605
- perform: () => load(),
19805
+ perform: quickLoad,
19606
19806
  disabled: !hasSave(),
19607
19807
  title: hotkeyTitle("Quick Load", quickLoadKey)
19608
19808
  };
@@ -19657,6 +19857,7 @@ defineMacro({
19657
19857
  const label = ctx.resolve(ctx.args.label ?? "");
19658
19858
  const passageName = ctx.collectText(children).trim().replace(/^["']|["']$/g, "");
19659
19859
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
19860
+ type: "button",
19660
19861
  id: ctx.id,
19661
19862
  class: ctx.cls,
19662
19863
  onClick: () => setOpen(true),
@@ -19696,15 +19897,19 @@ function SaveManagerContent() {
19696
19897
  const [status, setStatus] = useState(null);
19697
19898
  const [collapsed, setCollapsed] = useState(() => /* @__PURE__ */ new Set());
19698
19899
  const [renamingId, setRenamingId] = useState(null);
19900
+ const renameCancelled = useRef(false);
19699
19901
  const [renameValue, setRenameValue] = useState("");
19700
19902
  const renameInputRef = useRef(null);
19701
19903
  const fileInputRef = useRef(null);
19702
19904
  const closeDialog = useContext(DialogCloseContext);
19703
19905
  const { storyData, playthroughId, beginSave, loadFromPayload } = useStoryFields("storyData", "playthroughId", "beginSave", "loadFromPayload");
19704
19906
  const ifid = storyData?.ifid ?? "";
19907
+ const [temporary, setTemporary] = useState(false);
19705
19908
  const refresh = useCallback(async () => {
19706
19909
  if (!ifid) return;
19707
- setGroups(await getSavesGrouped(ifid));
19910
+ const data = await getSavesGrouped(ifid);
19911
+ setTemporary(getBackendType() === "memory");
19912
+ setGroups(data);
19708
19913
  setLoading(false);
19709
19914
  }, [ifid]);
19710
19915
  useEffect(() => {
@@ -19751,7 +19956,7 @@ function SaveManagerContent() {
19751
19956
  await saveWithHooks(void 0, void 0, beginSave, async (payload) => {
19752
19957
  return createSave(ifid, playthrough, payload);
19753
19958
  });
19754
- showStatus("Save created");
19959
+ showStatus(temporary ? "Save created (temporary)" : "Save created");
19755
19960
  await refresh();
19756
19961
  } catch {
19757
19962
  showStatus("Failed to create save", "error");
@@ -19794,6 +19999,7 @@ function SaveManagerContent() {
19794
19999
  }
19795
20000
  };
19796
20001
  const handleRenameStart = (save) => {
20002
+ renameCancelled.current = false;
19797
20003
  setRenamingId(save.meta.id);
19798
20004
  setRenameValue(save.meta.title);
19799
20005
  };
@@ -19810,7 +20016,15 @@ function SaveManagerContent() {
19810
20016
  };
19811
20017
  const handleRenameKeyDown = (e) => {
19812
20018
  if (e.key === "Enter") handleRenameConfirm();
19813
- else if (e.key === "Escape") setRenamingId(null);
20019
+ else if (e.key === "Escape") {
20020
+ e.preventDefault();
20021
+ e.stopPropagation();
20022
+ renameCancelled.current = true;
20023
+ setRenamingId(null);
20024
+ }
20025
+ };
20026
+ const handleRenameBlur = () => {
20027
+ if (!renameCancelled.current) handleRenameConfirm();
19814
20028
  };
19815
20029
  const handleExport = async (saveId) => {
19816
20030
  try {
@@ -19858,10 +20072,12 @@ function SaveManagerContent() {
19858
20072
  children: /* @__PURE__ */ jsxs("div", {
19859
20073
  class: "saves-mode-toggle",
19860
20074
  children: [/* @__PURE__ */ jsx("button", {
20075
+ type: "button",
19861
20076
  class: mode === "save" ? "active" : "",
19862
20077
  onClick: () => setMode("save"),
19863
20078
  children: "Save"
19864
20079
  }), /* @__PURE__ */ jsx("button", {
20080
+ type: "button",
19865
20081
  class: mode === "load" ? "active" : "",
19866
20082
  onClick: () => setMode("load"),
19867
20083
  children: "Load"
@@ -19869,9 +20085,15 @@ function SaveManagerContent() {
19869
20085
  })
19870
20086
  })
19871
20087
  }),
20088
+ temporary && /* @__PURE__ */ jsx("div", {
20089
+ class: "saves-notice",
20090
+ role: "status",
20091
+ children: "Saves can not be kept in this browser: they are lost when the page is reloaded or closed. Export a save to keep it."
20092
+ }),
19872
20093
  /* @__PURE__ */ jsxs("div", {
19873
20094
  class: "saves-toolbar",
19874
20095
  children: [/* @__PURE__ */ jsx("button", {
20096
+ type: "button",
19875
20097
  class: "saves-toolbar-button",
19876
20098
  onClick: handleImport,
19877
20099
  children: "Import"
@@ -19927,7 +20149,7 @@ function SaveManagerContent() {
19927
20149
  value: renameValue,
19928
20150
  onInput: (e) => setRenameValue(e.target.value),
19929
20151
  onKeyDown: handleRenameKeyDown,
19930
- onBlur: handleRenameConfirm
20152
+ onBlur: handleRenameBlur
19931
20153
  }) : /* @__PURE__ */ jsx("div", {
19932
20154
  class: "save-slot-title",
19933
20155
  children: save.meta.title
@@ -19939,25 +20161,30 @@ function SaveManagerContent() {
19939
20161
  class: "save-slot-actions",
19940
20162
  children: [
19941
20163
  mode === "save" ? /* @__PURE__ */ jsx("button", {
20164
+ type: "button",
19942
20165
  class: "save-slot-action primary",
19943
20166
  onClick: () => handleOverwrite(save.meta.id),
19944
20167
  children: "Save Here"
19945
20168
  }) : /* @__PURE__ */ jsx("button", {
20169
+ type: "button",
19946
20170
  class: "save-slot-action primary",
19947
20171
  onClick: () => handleLoad(save),
19948
20172
  children: "Load"
19949
20173
  }),
19950
20174
  /* @__PURE__ */ jsx("button", {
20175
+ type: "button",
19951
20176
  class: "save-slot-action",
19952
20177
  onClick: () => handleRenameStart(save),
19953
20178
  children: "Rename"
19954
20179
  }),
19955
20180
  /* @__PURE__ */ jsx("button", {
20181
+ type: "button",
19956
20182
  class: "save-slot-action",
19957
20183
  onClick: () => handleExport(save.meta.id),
19958
20184
  children: "Export"
19959
20185
  }),
19960
20186
  /* @__PURE__ */ jsx("button", {
20187
+ type: "button",
19961
20188
  class: "save-slot-action danger",
19962
20189
  onClick: () => handleDelete(save.meta.id),
19963
20190
  children: "Delete"
@@ -19965,6 +20192,7 @@ function SaveManagerContent() {
19965
20192
  ]
19966
20193
  })]
19967
20194
  }, save.meta.id)), mode === "save" && isCurrentPt && /* @__PURE__ */ jsx("button", {
20195
+ type: "button",
19968
20196
  class: "save-slot-new",
19969
20197
  onClick: handleNewSave,
19970
20198
  children: "+ New Save"
@@ -19976,6 +20204,7 @@ function SaveManagerContent() {
19976
20204
  children: /* @__PURE__ */ jsx("div", {
19977
20205
  class: "playthrough-saves",
19978
20206
  children: /* @__PURE__ */ jsx("button", {
20207
+ type: "button",
19979
20208
  class: "save-slot-new",
19980
20209
  onClick: handleNewSave,
19981
20210
  children: "+ New Save"
@@ -20347,6 +20576,7 @@ defineMacro({
20347
20576
  }
20348
20577
  });
20349
20578
  return /* @__PURE__ */ jsx("button", {
20579
+ type: "button",
20350
20580
  id: ctx.id,
20351
20581
  class: ctx.cls,
20352
20582
  onClick: handleClick,
@@ -20521,7 +20751,7 @@ function resolveTransition(targetTags, nextTransition, storeDefault) {
20521
20751
  /**
20522
20752
  * Create a snapshot clone of a .passage element for outgoing transitions.
20523
20753
  * - Strips `id` attributes from the clone tree
20524
- * - Disables interaction (pointer-events, user-select)
20754
+ * - Disables interaction (pointer-events, user-select, inert, aria-hidden)
20525
20755
  * - Pauses/mutes any <audio>/<video>
20526
20756
  * - Adds `.passage-snapshot` class and `data-transition` attribute
20527
20757
  */
@@ -20532,6 +20762,8 @@ function createSnapshot(passageEl, transitionType) {
20532
20762
  for (let i = 0; i < idEls.length; i++) idEls[i].removeAttribute("id");
20533
20763
  clone.style.pointerEvents = "none";
20534
20764
  clone.style.userSelect = "none";
20765
+ clone.setAttribute("inert", "");
20766
+ clone.setAttribute("aria-hidden", "true");
20535
20767
  const mediaEls = clone.querySelectorAll("audio, video");
20536
20768
  for (let i = 0; i < mediaEls.length; i++) {
20537
20769
  const media = mediaEls[i];
@@ -20570,6 +20802,19 @@ function cleanupSnapshots(containerEl) {
20570
20802
  const snapshots = containerEl.querySelectorAll(".passage-snapshot");
20571
20803
  for (let i = 0; i < snapshots.length; i++) snapshots[i].remove();
20572
20804
  }
20805
+ /**
20806
+ * `config` for players who prefer reduced motion: the CSS shortens the
20807
+ * animations (see styles.css), so the waits that follow them shrink too,
20808
+ * leaving no empty passage area before the next passage.
20809
+ */
20810
+ function reduceMotion(config) {
20811
+ if (!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return config;
20812
+ return {
20813
+ ...config,
20814
+ duration: Math.min(config.duration, 10),
20815
+ pause: 0
20816
+ };
20817
+ }
20573
20818
  defineMacro({
20574
20819
  name: "passage",
20575
20820
  interpolate: true,
@@ -20617,7 +20862,7 @@ defineMacro({
20617
20862
  setDisplayedPassage(currentPassage);
20618
20863
  return;
20619
20864
  }
20620
- const config = resolveTransition(targetPassage.tags, next, transitionConfig);
20865
+ const config = reduceMotion(resolveTransition(targetPassage.tags, next, transitionConfig));
20621
20866
  resolvedTypeRef.current = config.type;
20622
20867
  const containerEl = containerRef.current;
20623
20868
  if (config.type === "none") {
@@ -20757,7 +21002,7 @@ defineMacro({
20757
21002
  });
20758
21003
  //#endregion
20759
21004
  //#region src/styles.css?inline
20760
- 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";
21005
+ 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-notice {\n padding: 0.5em 1.25em;\n font-size: 0.8em;\n color: #ffb74d;\n border-bottom: 1px solid rgba(255, 183, 77, 0.3);\n background: rgba(255, 183, 77, 0.08);\n flex-shrink: 0;\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";
20761
21006
  //#endregion
20762
21007
  //#region src/index.tsx
20763
21008
  function renderErrors(root, errors) {