@rohal12/spindle 0.59.8 → 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.
@@ -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
  /**
@@ -13406,7 +13419,11 @@ function deepClone(value, options = {}) {
13406
13419
  }
13407
13420
  function cloneBuiltin(val) {
13408
13421
  if (val instanceof Date) return keep(val, new Date(val.getTime()));
13409
- 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
+ }
13410
13427
  if (val instanceof ArrayBuffer) return keep(val, val.slice(0));
13411
13428
  if (ArrayBuffer.isView(val)) {
13412
13429
  const buffer = clone(val.buffer);
@@ -13589,7 +13606,7 @@ function equalBytes(a, b) {
13589
13606
  /** Equality of the content of atomic built-ins (see isAtomic) but Map/Set. */
13590
13607
  function equalBuiltin(a, b, assumed) {
13591
13608
  if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
13592
- if (a instanceof RegExp) return String(a) === String(b);
13609
+ if (a instanceof RegExp) return String(a) === String(b) && a.lastIndex === b.lastIndex;
13593
13610
  if (ArrayBuffer.isView(a)) {
13594
13611
  const v = b;
13595
13612
  return a.byteOffset === v.byteOffset && a.byteLength === v.byteLength && equal(a.buffer, v.buffer, assumed);
@@ -14148,15 +14165,17 @@ var readingSlot = (read) => queued(async (ifid, slot) => read(await slotRecord(i
14148
14165
  var startNewPlaythrough = queued(startNewPlaythroughNow);
14149
14166
  async function startNewPlaythroughNow(ifid, id = randomUUID()) {
14150
14167
  const backend = await getBackend();
14151
- const num = await nextPlaythroughNumber(ifid);
14152
- const record = {
14153
- id,
14154
- ifid,
14155
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
14156
- label: `Playthrough ${num}`
14157
- };
14158
- await backend.putPlaythrough(record);
14159
- 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
+ });
14160
14179
  await backend.setMeta(currentPlaythroughKey(ifid), id);
14161
14180
  return id;
14162
14181
  }
@@ -14409,12 +14428,41 @@ function slotMetaKey(ifid, slot) {
14409
14428
  return isNamedSlot(slot) ? `${SLOT_KEY_PREFIX}${slot}.${ifid}` : `${AUTOSAVE_KEY_PREFIX}${ifid}`;
14410
14429
  }
14411
14430
  var slotIndexKey = (ifid) => `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
14412
- /** 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
+ */
14413
14459
  async function updateSlotIndex(ifid, update) {
14414
14460
  const backend = await getBackend();
14415
- const existing = await backend.getMeta(slotIndexKey(ifid)) ?? [];
14416
- const updated = update(existing);
14417
- 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
+ });
14418
14466
  }
14419
14467
  /** Every slot: the default one (undefined), then those in the slot index. */
14420
14468
  async function allSlots(ifid) {
@@ -14761,6 +14809,11 @@ function executeStoryInit() {
14761
14809
  */
14762
14810
  function initializeStory(session) {
14763
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
+ }
14764
14817
  if (session) useStoryStore.getState().loadFromPayload(session);
14765
14818
  emit("storyinit");
14766
14819
  if (!session) recordStoryInitState();
@@ -15254,6 +15307,27 @@ function addMacroTrigger(condition, options) {
15254
15307
  if (triggers.some((t) => t.macroKey === macroKey)) return;
15255
15308
  registerTrigger(condition, options, macroKey);
15256
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
+ }
15257
15331
  function removeTrigger(name) {
15258
15332
  triggers = triggers.filter((t) => t.name !== name);
15259
15333
  }
@@ -15609,7 +15683,8 @@ function persistSession(get) {
15609
15683
  historyIndex,
15610
15684
  visitCounts,
15611
15685
  renderCounts,
15612
- prng: snapshotPRNG()
15686
+ prng: snapshotPRNG(),
15687
+ watchers: savedMacroWatchers()
15613
15688
  });
15614
15689
  }
15615
15690
  /** What the page shows before a session write error (see RuntimeErrors). */
@@ -15994,6 +16069,15 @@ function restorePRNGFrom(prng) {
15994
16069
  function restorePRNGFromMoment(moment) {
15995
16070
  if (moment) restorePRNGFrom(moment.prng);
15996
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
+ }
15997
16081
  var NAMESPACE_KEYS = [
15998
16082
  "variables",
15999
16083
  "temporary",
@@ -16421,7 +16505,8 @@ var useStoryStore = create()(storyStore((set, get) => ({
16421
16505
  historyIndex,
16422
16506
  visitCounts: { ...visitCounts },
16423
16507
  renderCounts: { ...renderCounts },
16424
- prng: snapshotPRNG()
16508
+ prng: snapshotPRNG(),
16509
+ watchers: savedMacroWatchers()
16425
16510
  };
16426
16511
  },
16427
16512
  beginSave: () => {
@@ -16433,12 +16518,15 @@ var useStoryStore = create()(storyStore((set, get) => ({
16433
16518
  };
16434
16519
  },
16435
16520
  loadFromPayload: (payload, slot, playthroughId) => {
16436
- const replacement = slotLoadApplying ?? ++stateReplacementsIssued;
16521
+ const applying = slotLoadApplying;
16437
16522
  slotLoadApplying = null;
16523
+ const replacement = applying ?? ++stateReplacementsIssued;
16438
16524
  if (payload.history.length === 0) {
16439
16525
  console.warn("loadFromPayload: rejecting payload with empty history");
16440
16526
  return;
16441
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`);
16442
16530
  latestStateApplied = replacement;
16443
16531
  emit("beforeload", slot);
16444
16532
  const ifid = get().storyData?.ifid;
@@ -16464,6 +16552,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
16464
16552
  state.temporary = createNamespace();
16465
16553
  state.transient = createNamespace(deepClone(get().transientDefaults));
16466
16554
  });
16555
+ if (payload.watchers) restoreMacroWatchers(payload.watchers);
16467
16556
  reinitTriggerState();
16468
16557
  lastNavigationVars = reconstructVarsAt(get().historyIndex);
16469
16558
  restorePRNGFrom(entry?.prng !== void 0 ? entry.prng : payload.prng);
@@ -16646,7 +16735,7 @@ function PassageDialog({ passageName, fallbackMarkup, panelClass, onClose, dismi
16646
16735
  }, []);
16647
16736
  const handleBackdrop = (e) => {
16648
16737
  if (!dismissible) return;
16649
- if (e.target.classList.contains("dialog-overlay")) stableOnClose();
16738
+ if (e.target === e.currentTarget) stableOnClose();
16650
16739
  };
16651
16740
  const cls = panelClass ? `dialog-panel ${panelClass}` : "dialog-panel";
16652
16741
  return /* @__PURE__ */ jsx(DialogCloseContext.Provider, {
@@ -16661,6 +16750,7 @@ function PassageDialog({ passageName, fallbackMarkup, panelClass, onClose, dismi
16661
16750
  "aria-modal": "true",
16662
16751
  tabIndex: -1,
16663
16752
  children: [showCloseButton && /* @__PURE__ */ jsx("button", {
16753
+ type: "button",
16664
16754
  class: "dialog-close",
16665
16755
  "aria-label": "Close",
16666
16756
  onClick: stableOnClose,
@@ -19086,7 +19176,7 @@ function keyOf(val, ancestors) {
19086
19176
  const depth = ancestors.indexOf(obj);
19087
19177
  if (depth !== -1) return `<cycle ${ancestors.length - depth}>`;
19088
19178
  if (val instanceof Date) return `Date(${val.getTime()})`;
19089
- if (val instanceof RegExp) return `RegExp(${String(val)})`;
19179
+ if (val instanceof RegExp) return `RegExp(${String(val)}@${val.lastIndex})`;
19090
19180
  ancestors.push(obj);
19091
19181
  try {
19092
19182
  if (Array.isArray(val)) return `[${Array.from(val, (v) => keyOf(v, ancestors)).join(",")}]`;
@@ -19201,15 +19291,7 @@ defineMacro({
19201
19291
  block: true,
19202
19292
  render({ children = [] }, ctx) {
19203
19293
  const code = ctx.collectText(children);
19204
- ctx.hooks.useLayoutEffect(() => {
19205
- const location = ctx.sourceLocation();
19206
- try {
19207
- ctx.mutate(code);
19208
- } catch (err) {
19209
- logMacroError("do", err, location);
19210
- }
19211
- }, []);
19212
- return null;
19294
+ return useRunOnce("do", "", () => ctx.mutate(code));
19213
19295
  }
19214
19296
  });
19215
19297
  //#endregion
@@ -19361,6 +19443,7 @@ defineMacro({
19361
19443
  perform: handleClick
19362
19444
  });
19363
19445
  return /* @__PURE__ */ jsx("button", {
19446
+ type: "button",
19364
19447
  id: ctx.id,
19365
19448
  class: ctx.cls,
19366
19449
  onClick: handleClick,
@@ -19517,15 +19600,52 @@ defineMacro({
19517
19600
  });
19518
19601
  //#endregion
19519
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
+ }
19520
19638
  defineMacro({
19521
19639
  name: "type",
19522
19640
  block: true,
19523
19641
  interpolate: true,
19524
19642
  parameters: [DELAY_PARAMETER],
19525
19643
  render({ children = [] }, ctx) {
19526
- const { useState, useEffect, useRef } = ctx.hooks;
19644
+ const { useState, useEffect, useLayoutEffect, useRef } = ctx.hooks;
19527
19645
  const speed = ctx.args.delay ?? 0;
19528
19646
  const containerRef = useRef(null);
19647
+ const innerRef = useRef(null);
19648
+ const [clip, setClip] = useState();
19529
19649
  const [totalChars, setTotalChars] = useState(0);
19530
19650
  const [visibleChars, setVisibleChars] = useState(0);
19531
19651
  const visibleCharsRef = useRef(0);
@@ -19548,6 +19668,22 @@ defineMacro({
19548
19668
  return () => clearInterval(timer);
19549
19669
  }, [totalChars, speed]);
19550
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
+ ]);
19551
19687
  const cls = [
19552
19688
  "macro-type",
19553
19689
  done ? "macro-type-done" : "",
@@ -19557,13 +19693,13 @@ defineMacro({
19557
19693
  id: ctx.id,
19558
19694
  class: cls,
19559
19695
  ref: containerRef,
19560
- style: { clipPath: totalChars > 0 && !done ? void 0 : void 0 },
19561
19696
  children: [/* @__PURE__ */ jsx("span", {
19697
+ ref: innerRef,
19562
19698
  class: "macro-type-inner",
19563
19699
  style: {
19564
19700
  display: "inline",
19565
19701
  visibility: totalChars === 0 ? "hidden" : "visible",
19566
- clipPath: totalChars > 0 && !done ? `inset(0 ${(totalChars - visibleChars) / totalChars * 100}% 0 0)` : void 0
19702
+ clipPath: clip
19567
19703
  },
19568
19704
  children: ctx.renderInlineNodes(children)
19569
19705
  }), !done && totalChars > 0 && /* @__PURE__ */ jsx("span", { class: "macro-type-cursor" })]
@@ -19611,6 +19747,7 @@ function defineMenubarAction(config) {
19611
19747
  perform: config.dialog ? () => setDialogOpen(true) : setup.perform
19612
19748
  });
19613
19749
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
19750
+ type: "button",
19614
19751
  id: ctx.id,
19615
19752
  class: cls,
19616
19753
  onClick: perform,
@@ -19720,6 +19857,7 @@ defineMacro({
19720
19857
  const label = ctx.resolve(ctx.args.label ?? "");
19721
19858
  const passageName = ctx.collectText(children).trim().replace(/^["']|["']$/g, "");
19722
19859
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
19860
+ type: "button",
19723
19861
  id: ctx.id,
19724
19862
  class: ctx.cls,
19725
19863
  onClick: () => setOpen(true),
@@ -19766,9 +19904,12 @@ function SaveManagerContent() {
19766
19904
  const closeDialog = useContext(DialogCloseContext);
19767
19905
  const { storyData, playthroughId, beginSave, loadFromPayload } = useStoryFields("storyData", "playthroughId", "beginSave", "loadFromPayload");
19768
19906
  const ifid = storyData?.ifid ?? "";
19907
+ const [temporary, setTemporary] = useState(false);
19769
19908
  const refresh = useCallback(async () => {
19770
19909
  if (!ifid) return;
19771
- setGroups(await getSavesGrouped(ifid));
19910
+ const data = await getSavesGrouped(ifid);
19911
+ setTemporary(getBackendType() === "memory");
19912
+ setGroups(data);
19772
19913
  setLoading(false);
19773
19914
  }, [ifid]);
19774
19915
  useEffect(() => {
@@ -19815,7 +19956,7 @@ function SaveManagerContent() {
19815
19956
  await saveWithHooks(void 0, void 0, beginSave, async (payload) => {
19816
19957
  return createSave(ifid, playthrough, payload);
19817
19958
  });
19818
- showStatus("Save created");
19959
+ showStatus(temporary ? "Save created (temporary)" : "Save created");
19819
19960
  await refresh();
19820
19961
  } catch {
19821
19962
  showStatus("Failed to create save", "error");
@@ -19931,10 +20072,12 @@ function SaveManagerContent() {
19931
20072
  children: /* @__PURE__ */ jsxs("div", {
19932
20073
  class: "saves-mode-toggle",
19933
20074
  children: [/* @__PURE__ */ jsx("button", {
20075
+ type: "button",
19934
20076
  class: mode === "save" ? "active" : "",
19935
20077
  onClick: () => setMode("save"),
19936
20078
  children: "Save"
19937
20079
  }), /* @__PURE__ */ jsx("button", {
20080
+ type: "button",
19938
20081
  class: mode === "load" ? "active" : "",
19939
20082
  onClick: () => setMode("load"),
19940
20083
  children: "Load"
@@ -19942,9 +20085,15 @@ function SaveManagerContent() {
19942
20085
  })
19943
20086
  })
19944
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
+ }),
19945
20093
  /* @__PURE__ */ jsxs("div", {
19946
20094
  class: "saves-toolbar",
19947
20095
  children: [/* @__PURE__ */ jsx("button", {
20096
+ type: "button",
19948
20097
  class: "saves-toolbar-button",
19949
20098
  onClick: handleImport,
19950
20099
  children: "Import"
@@ -20012,25 +20161,30 @@ function SaveManagerContent() {
20012
20161
  class: "save-slot-actions",
20013
20162
  children: [
20014
20163
  mode === "save" ? /* @__PURE__ */ jsx("button", {
20164
+ type: "button",
20015
20165
  class: "save-slot-action primary",
20016
20166
  onClick: () => handleOverwrite(save.meta.id),
20017
20167
  children: "Save Here"
20018
20168
  }) : /* @__PURE__ */ jsx("button", {
20169
+ type: "button",
20019
20170
  class: "save-slot-action primary",
20020
20171
  onClick: () => handleLoad(save),
20021
20172
  children: "Load"
20022
20173
  }),
20023
20174
  /* @__PURE__ */ jsx("button", {
20175
+ type: "button",
20024
20176
  class: "save-slot-action",
20025
20177
  onClick: () => handleRenameStart(save),
20026
20178
  children: "Rename"
20027
20179
  }),
20028
20180
  /* @__PURE__ */ jsx("button", {
20181
+ type: "button",
20029
20182
  class: "save-slot-action",
20030
20183
  onClick: () => handleExport(save.meta.id),
20031
20184
  children: "Export"
20032
20185
  }),
20033
20186
  /* @__PURE__ */ jsx("button", {
20187
+ type: "button",
20034
20188
  class: "save-slot-action danger",
20035
20189
  onClick: () => handleDelete(save.meta.id),
20036
20190
  children: "Delete"
@@ -20038,6 +20192,7 @@ function SaveManagerContent() {
20038
20192
  ]
20039
20193
  })]
20040
20194
  }, save.meta.id)), mode === "save" && isCurrentPt && /* @__PURE__ */ jsx("button", {
20195
+ type: "button",
20041
20196
  class: "save-slot-new",
20042
20197
  onClick: handleNewSave,
20043
20198
  children: "+ New Save"
@@ -20049,6 +20204,7 @@ function SaveManagerContent() {
20049
20204
  children: /* @__PURE__ */ jsx("div", {
20050
20205
  class: "playthrough-saves",
20051
20206
  children: /* @__PURE__ */ jsx("button", {
20207
+ type: "button",
20052
20208
  class: "save-slot-new",
20053
20209
  onClick: handleNewSave,
20054
20210
  children: "+ New Save"
@@ -20420,6 +20576,7 @@ defineMacro({
20420
20576
  }
20421
20577
  });
20422
20578
  return /* @__PURE__ */ jsx("button", {
20579
+ type: "button",
20423
20580
  id: ctx.id,
20424
20581
  class: ctx.cls,
20425
20582
  onClick: handleClick,
@@ -20594,7 +20751,7 @@ function resolveTransition(targetTags, nextTransition, storeDefault) {
20594
20751
  /**
20595
20752
  * Create a snapshot clone of a .passage element for outgoing transitions.
20596
20753
  * - Strips `id` attributes from the clone tree
20597
- * - Disables interaction (pointer-events, user-select)
20754
+ * - Disables interaction (pointer-events, user-select, inert, aria-hidden)
20598
20755
  * - Pauses/mutes any <audio>/<video>
20599
20756
  * - Adds `.passage-snapshot` class and `data-transition` attribute
20600
20757
  */
@@ -20605,6 +20762,8 @@ function createSnapshot(passageEl, transitionType) {
20605
20762
  for (let i = 0; i < idEls.length; i++) idEls[i].removeAttribute("id");
20606
20763
  clone.style.pointerEvents = "none";
20607
20764
  clone.style.userSelect = "none";
20765
+ clone.setAttribute("inert", "");
20766
+ clone.setAttribute("aria-hidden", "true");
20608
20767
  const mediaEls = clone.querySelectorAll("audio, video");
20609
20768
  for (let i = 0; i < mediaEls.length; i++) {
20610
20769
  const media = mediaEls[i];
@@ -20843,7 +21002,7 @@ defineMacro({
20843
21002
  });
20844
21003
  //#endregion
20845
21004
  //#region src/styles.css?inline
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";
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";
20847
21006
  //#endregion
20848
21007
  //#region src/index.tsx
20849
21008
  function renderErrors(root, errors) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.8",
3
+ "version": "0.59.9",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -51,7 +51,9 @@ export function clearRegistry(): void {
51
51
  // with its message, cause, errors and own enumerable keys (not its stack);
52
52
  // - `S`: a symbol from the global registry (Symbol.for);
53
53
  // - `K`: a plain object holding a one-character key that JSON escapes (see
54
- // GUARD_V8_KEYS).
54
+ // GUARD_V8_KEYS);
55
+ // - `R`: a RegExp whose lastIndex is not zero (the scanning cursor of a
56
+ // global or sticky pattern), with its source, flags and lastIndex.
55
57
  //
56
58
  // Everything else is refused when saving, with the path to the value:
57
59
  // functions, unique symbols, symbol keys, instances of unregistered classes,
@@ -61,6 +63,7 @@ const CLASS_PREFIX = 'c:';
61
63
  const ERROR_PREFIX = 'E:';
62
64
  const SYMBOL_TAG = 'S';
63
65
  const KEYS_TAG = 'K';
66
+ const REGEXP_TAG = 'R';
64
67
 
65
68
  /**
66
69
  * Work around Chromium issue 521080746 (V8 in Chrome/Chromium 14x-153+,
@@ -167,6 +170,9 @@ function reducers(): Record<string, (value: unknown) => unknown> {
167
170
  const key = Symbol.keyFor(v);
168
171
  return key !== undefined && [key];
169
172
  };
173
+ out[REGEXP_TAG] = (v) =>
174
+ v instanceof RegExp &&
175
+ v.lastIndex !== 0 && [v.source, v.flags, v.lastIndex];
170
176
  if (GUARD_V8_KEYS) {
171
177
  out[KEYS_TAG] = (v) => {
172
178
  if (!isObject(v) || Object.getPrototypeOf(v) !== Object.prototype) {
@@ -264,6 +270,21 @@ function reviverFor(key: string | symbol) {
264
270
  };
265
271
  }
266
272
  if (key === KEYS_TAG) return reviveKeyed;
273
+ if (key === REGEXP_TAG) {
274
+ return (data: unknown) => {
275
+ if (
276
+ !Array.isArray(data) ||
277
+ typeof data[0] !== 'string' ||
278
+ typeof data[1] !== 'string' ||
279
+ !Number.isSafeInteger(data[2])
280
+ ) {
281
+ throw new TypeError('spindle: Malformed regular expression');
282
+ }
283
+ const re = new RegExp(data[0], data[1]);
284
+ re.lastIndex = data[2] as number;
285
+ return re;
286
+ };
287
+ }
267
288
  if (key.startsWith(CLASS_PREFIX)) {
268
289
  return reviveClass(key.slice(CLASS_PREFIX.length));
269
290
  }