@rohal12/spindle 0.59.8 → 0.59.10

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.
@@ -9735,13 +9735,24 @@ function tokenizeMarkup(source, options = {}) {
9735
9735
  //#endregion
9736
9736
  //#region src/hooks/use-story-fields.ts
9737
9737
  /**
9738
+ * Tells the components below when to keep the state they last rendered with,
9739
+ * given the store's current state: an owner (a click body, see
9740
+ * useDetachedBody; a passage on its way out, see PassageDisplay) provides it
9741
+ * so that a branch skipped earlier cannot run against later state (#351,
9742
+ * #352). It runs inside the store's own notification, before the owner could
9743
+ * re-render, which is why it is a predicate and not a flag.
9744
+ */
9745
+ var FrozenStateContext = createContext(null);
9746
+ /**
9738
9747
  * Story state fields `keys`, re-rendering when any of them changes (as one
9739
9748
  * useStoryStore() selector per field would). The result keeps its identity
9740
9749
  * while the fields don't change, as the store's snapshot must.
9741
9750
  */
9742
9751
  function useStoryFields(...keys) {
9743
9752
  const prev = useRef();
9753
+ const freeze = useContext(FrozenStateContext);
9744
9754
  return useStoryStore((s) => {
9755
+ if (prev.current && freeze?.(s)) return prev.current;
9745
9756
  const next = {};
9746
9757
  for (const key of keys) next[key] = s[key];
9747
9758
  return shallow(prev.current, next) ? prev.current : prev.current = next;
@@ -9754,7 +9765,7 @@ function useStoryFields(...keys) {
9754
9765
  * All four dicts use unprefixed keys suitable for evaluate/execute.
9755
9766
  */
9756
9767
  function useMergedLocals() {
9757
- const { variables, temporary, transient } = useStoryFields("variables", "temporary", "transient");
9768
+ const { variables, temporary, transient, renderCounts } = useStoryFields("variables", "temporary", "transient", "renderCounts");
9758
9769
  const localsValues = useContext(LocalsValuesContext);
9759
9770
  return useMemo(() => {
9760
9771
  return [
@@ -9767,7 +9778,8 @@ function useMergedLocals() {
9767
9778
  variables,
9768
9779
  temporary,
9769
9780
  localsValues,
9770
- transient
9781
+ transient,
9782
+ renderCounts
9771
9783
  ]);
9772
9784
  }
9773
9785
  //#endregion
@@ -10887,7 +10899,17 @@ function LocalsScope({ body, parentValues, ownKeys }) {
10887
10899
  }
10888
10900
  //#endregion
10889
10901
  //#region src/components/macros/WidgetInvocation.tsx
10890
- function WidgetInvocation({ body, params, rawArgs, invocationChildren }) {
10902
+ function WidgetInvocation(props) {
10903
+ const { className, id } = props;
10904
+ const content = /* @__PURE__ */ jsx(WidgetContent, { ...props });
10905
+ if (!className && !id) return content;
10906
+ return /* @__PURE__ */ jsx("span", {
10907
+ class: className,
10908
+ id,
10909
+ children: content
10910
+ });
10911
+ }
10912
+ function WidgetContent({ body, params, rawArgs, invocationChildren, className, id }) {
10891
10913
  const renderOptions = useRenderOptions();
10892
10914
  const parentValues = renderOptions.locals;
10893
10915
  const [mergedVars, mergedTemps, mergedLocals, mergedTrans] = useMergedLocals();
@@ -11143,7 +11165,7 @@ function useRunOnce(macro, rawArgs, effect) {
11143
11165
  effect();
11144
11166
  } catch (error) {
11145
11167
  failure.current = { error };
11146
- logMacroError(`${macro} ${rawArgs}`, error, location);
11168
+ logMacroError(`${macro} ${rawArgs}`.trim(), error, location);
11147
11169
  }
11148
11170
  }
11149
11171
  if (!failure.current) return null;
@@ -11643,18 +11665,21 @@ function nodeKey(node) {
11643
11665
  }
11644
11666
  function renderMacro(node, key) {
11645
11667
  if (isSubMacro(node.name)) return null;
11668
+ const invocation = {
11669
+ rawArgs: node.rawArgs,
11670
+ className: node.className,
11671
+ id: node.id
11672
+ };
11646
11673
  const widget = getWidget(node.name);
11647
11674
  if (widget) return /* @__PURE__ */ jsx(WidgetInvocation, {
11675
+ ...invocation,
11648
11676
  body: widget.body,
11649
11677
  params: widget.params,
11650
- rawArgs: node.rawArgs,
11651
11678
  invocationChildren: node.children
11652
11679
  }, key);
11653
11680
  const Component = getMacro(node.name);
11654
11681
  if (Component) return /* @__PURE__ */ jsx(Component, {
11655
- rawArgs: node.rawArgs,
11656
- className: node.className,
11657
- id: node.id,
11682
+ ...invocation,
11658
11683
  children: node.children,
11659
11684
  branches: node.branches
11660
11685
  }, key);
@@ -12548,6 +12573,7 @@ var CLASS_PREFIX = "c:";
12548
12573
  var ERROR_PREFIX = "E:";
12549
12574
  var SYMBOL_TAG = "S";
12550
12575
  var KEYS_TAG = "K";
12576
+ var REGEXP_TAG = "R";
12551
12577
  var BUILTIN_ERRORS = new Map([
12552
12578
  Error,
12553
12579
  EvalError,
@@ -12609,6 +12635,11 @@ function reducers() {
12609
12635
  const key = Symbol.keyFor(v);
12610
12636
  return key !== void 0 && [key];
12611
12637
  };
12638
+ out[REGEXP_TAG] = (v) => v instanceof RegExp && v.lastIndex !== 0 && [
12639
+ v.source,
12640
+ v.flags,
12641
+ v.lastIndex
12642
+ ];
12612
12643
  out[KEYS_TAG] = (v) => {
12613
12644
  if (!isObject(v) || Object.getPrototypeOf(v) !== Object.prototype) return false;
12614
12645
  const keys = Object.keys(v);
@@ -12682,6 +12713,12 @@ function reviverFor(key) {
12682
12713
  return Symbol.for(data[0]);
12683
12714
  };
12684
12715
  if (key === KEYS_TAG) return reviveKeyed;
12716
+ if (key === REGEXP_TAG) return (data) => {
12717
+ if (!Array.isArray(data) || typeof data[0] !== "string" || typeof data[1] !== "string" || !Number.isSafeInteger(data[2])) throw new TypeError("spindle: Malformed regular expression");
12718
+ const re = new RegExp(data[0], data[1]);
12719
+ re.lastIndex = data[2];
12720
+ return re;
12721
+ };
12685
12722
  if (key.startsWith(CLASS_PREFIX)) return reviveClass(key.slice(2));
12686
12723
  if (key.startsWith(ERROR_PREFIX)) return reviveError(key.slice(2));
12687
12724
  }
@@ -12793,6 +12830,7 @@ function isOptionalPRNGSnapshot(value) {
12793
12830
  return isRecord$1(value) && typeof value.seed === "string" && typeof value.pull === "number" && Number.isInteger(value.pull) && value.pull >= 0;
12794
12831
  }
12795
12832
  var isMoment = (value) => isRecord$1(value) && typeof value.passage === "string" && isRecord$1(value.variables) && typeof value.timestamp === "number" && isOptionalPRNGSnapshot(value.prng);
12833
+ 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
12834
  var isCountsMap = (value) => value === void 0 || value instanceof Map && [...value.keys()].every((k) => typeof k === "string");
12797
12835
  /**
12798
12836
  * Whether a deserialized payload is well formed: every history moment is,
@@ -12806,7 +12844,7 @@ function isPayload(value) {
12806
12844
  if (!Array.isArray(history) || history.length === 0) return false;
12807
12845
  if (!Array.from(history).every(isMoment)) return false;
12808
12846
  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);
12847
+ return isCountsMap(value.visitCounts) && isCountsMap(value.renderCounts) && isOptionalPRNGSnapshot(value.prng) && isWatchers(value.watchers);
12810
12848
  }
12811
12849
  var mapToCounts = (counts) => counts instanceof Map ? createCounts(Object.fromEntries(counts)) : void 0;
12812
12850
  /**
@@ -13406,7 +13444,11 @@ function deepClone(value, options = {}) {
13406
13444
  }
13407
13445
  function cloneBuiltin(val) {
13408
13446
  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));
13447
+ if (val instanceof RegExp) {
13448
+ const copy = new RegExp(val.source, val.flags);
13449
+ copy.lastIndex = val.lastIndex;
13450
+ return keep(val, copy);
13451
+ }
13410
13452
  if (val instanceof ArrayBuffer) return keep(val, val.slice(0));
13411
13453
  if (ArrayBuffer.isView(val)) {
13412
13454
  const buffer = clone(val.buffer);
@@ -13589,7 +13631,7 @@ function equalBytes(a, b) {
13589
13631
  /** Equality of the content of atomic built-ins (see isAtomic) but Map/Set. */
13590
13632
  function equalBuiltin(a, b, assumed) {
13591
13633
  if (a instanceof Date) return Object.is(a.getTime(), b.getTime());
13592
- if (a instanceof RegExp) return String(a) === String(b);
13634
+ if (a instanceof RegExp) return String(a) === String(b) && a.lastIndex === b.lastIndex;
13593
13635
  if (ArrayBuffer.isView(a)) {
13594
13636
  const v = b;
13595
13637
  return a.byteOffset === v.byteOffset && a.byteLength === v.byteLength && equal(a.buffer, v.buffer, assumed);
@@ -14148,16 +14190,19 @@ var readingSlot = (read) => queued(async (ifid, slot) => read(await slotRecord(i
14148
14190
  var startNewPlaythrough = queued(startNewPlaythroughNow);
14149
14191
  async function startNewPlaythroughNow(ifid, id = randomUUID()) {
14150
14192
  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);
14193
+ await withTabLock(`spindle-playthrough-count:${ifid}`, async () => {
14194
+ const num = await nextPlaythroughNumber(ifid);
14195
+ const record = {
14196
+ id,
14197
+ ifid,
14198
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
14199
+ label: `Playthrough ${num}`
14200
+ };
14201
+ await backend.putPlaythrough(record);
14202
+ await backend.setMeta(playthroughCountKey(ifid), num);
14203
+ });
14160
14204
  await backend.setMeta(currentPlaythroughKey(ifid), id);
14205
+ saveSessionPlaythrough(ifid, id);
14161
14206
  return id;
14162
14207
  }
14163
14208
  var PLAYTHROUGH_LABEL = /^Playthrough (\d+)$/;
@@ -14197,6 +14242,7 @@ async function adoptPlaythroughNow(ifid, id) {
14197
14242
  await ensurePlaythrough(ifid, id, (/* @__PURE__ */ new Date()).toISOString());
14198
14243
  const key = currentPlaythroughKey(ifid);
14199
14244
  if (await backend.getMeta(key) !== id) await backend.setMeta(key, id);
14245
+ saveSessionPlaythrough(ifid, id);
14200
14246
  }
14201
14247
  /**
14202
14248
  * Record the playthrough `id` of the story as "Imported", created at
@@ -14219,8 +14265,12 @@ async function ensurePlaythrough(ifid, id, createdAt) {
14219
14265
  */
14220
14266
  function establishPlaythrough(ifid) {
14221
14267
  return inOrder(async () => {
14268
+ const backend = await getBackend();
14269
+ const own = loadSessionPlaythrough(ifid);
14270
+ const id = own !== void 0 && (await backend.getPlaythroughsByIfid(ifid)).some((p) => p.id === own) ? own : await backend.getMeta(currentPlaythroughKey(ifid)) ?? await startNewPlaythroughNow(ifid);
14271
+ saveSessionPlaythrough(ifid, id);
14222
14272
  return {
14223
- id: await (await getBackend()).getMeta(currentPlaythroughKey(ifid)) ?? await startNewPlaythroughNow(ifid),
14273
+ id,
14224
14274
  knownSaves: await populateKnownSavesNow(ifid)
14225
14275
  };
14226
14276
  });
@@ -14409,12 +14459,41 @@ function slotMetaKey(ifid, slot) {
14409
14459
  return isNamedSlot(slot) ? `${SLOT_KEY_PREFIX}${slot}.${ifid}` : `${AUTOSAVE_KEY_PREFIX}${ifid}`;
14410
14460
  }
14411
14461
  var slotIndexKey = (ifid) => `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
14412
- /** Read-modify-write the per-story slot index. */
14462
+ /**
14463
+ * Run `op` holding the browser's lock `name`, so other tabs of the story
14464
+ * (the operation queue only orders this document's operations) wait for it.
14465
+ * Runs `op` without a lock where the browser offers none (or refuses it, as
14466
+ * in a sandboxed frame, which has no other tabs sharing its storage).
14467
+ */
14468
+ async function withTabLock(name, op) {
14469
+ let locks;
14470
+ try {
14471
+ locks = navigator.locks;
14472
+ } catch {}
14473
+ if (!locks) return op();
14474
+ let started = false;
14475
+ try {
14476
+ return await locks.request(name, () => {
14477
+ started = true;
14478
+ return op();
14479
+ });
14480
+ } catch (err) {
14481
+ if (started) throw err;
14482
+ return op();
14483
+ }
14484
+ }
14485
+ /**
14486
+ * Read-modify-write the per-story slot index. Under a lock shared by the
14487
+ * story's tabs: two tabs adding a slot at once would both read the old index
14488
+ * and the later write would drop the other's slot.
14489
+ */
14413
14490
  async function updateSlotIndex(ifid, update) {
14414
14491
  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);
14492
+ await withTabLock(`spindle-slot-index:${ifid}`, async () => {
14493
+ const existing = await backend.getMeta(slotIndexKey(ifid)) ?? [];
14494
+ const updated = update(existing);
14495
+ if (updated !== existing) await backend.setMeta(slotIndexKey(ifid), updated);
14496
+ });
14418
14497
  }
14419
14498
  /** Every slot: the default one (undefined), then those in the slot index. */
14420
14499
  async function allSlots(ifid) {
@@ -14494,12 +14573,16 @@ readingSlot(livePayload);
14494
14573
  * (see adoptPlaythrough) in the same operation: operations issued after the
14495
14574
  * load take effect in the loaded playthrough. Resolves to the live payload
14496
14575
  * and its playthrough, or undefined if the slot is empty.
14576
+ *
14577
+ * `check` is run on the payload first: when it throws, the load rejects and
14578
+ * the game stays in its playthrough (#357).
14497
14579
  */
14498
- function loadSlotSave(ifid, slot) {
14580
+ function loadSlotSave(ifid, slot, check) {
14499
14581
  return inOrder(async () => {
14500
14582
  const record = await slotRecord(ifid, slot);
14501
14583
  if (!record) return void 0;
14502
14584
  const payload = decodePayload(record.payload);
14585
+ check?.(payload);
14503
14586
  const { playthroughId } = record.meta;
14504
14587
  if (playthroughId) await adoptPlaythroughNow(ifid, playthroughId);
14505
14588
  return {
@@ -14589,6 +14672,26 @@ function clearSession(ifid) {
14589
14672
  sessionStorage.removeItem(`${SESSION_KEY_PREFIX}${ifid}`);
14590
14673
  } catch {}
14591
14674
  }
14675
+ /**
14676
+ * The playthrough this tab's game is in, kept with its session: the shared
14677
+ * current playthrough (a stored meta value) can be changed by another tab.
14678
+ * Written where this tab's storage operations set the current playthrough,
14679
+ * so it follows them in call order. Under the session prefix, so clearing
14680
+ * all data clears it.
14681
+ */
14682
+ var SESSION_PLAYTHROUGH_PREFIX = `${SESSION_KEY_PREFIX}playthrough.`;
14683
+ function saveSessionPlaythrough(ifid, id) {
14684
+ try {
14685
+ sessionStorage.setItem(`${SESSION_PLAYTHROUGH_PREFIX}${ifid}`, id);
14686
+ } catch {}
14687
+ }
14688
+ function loadSessionPlaythrough(ifid) {
14689
+ try {
14690
+ return sessionStorage.getItem(`${SESSION_PLAYTHROUGH_PREFIX}${ifid}`) || void 0;
14691
+ } catch {
14692
+ return;
14693
+ }
14694
+ }
14592
14695
  /** The export of a stored save, if there is one. */
14593
14696
  function toExport(record) {
14594
14697
  if (!record) return void 0;
@@ -14761,6 +14864,11 @@ function executeStoryInit() {
14761
14864
  */
14762
14865
  function initializeStory(session) {
14763
14866
  executeStoryInit();
14867
+ const missing = session ? missingPassage(useStoryStore.getState().storyData, session) : void 0;
14868
+ if (missing !== void 0) {
14869
+ console.warn(`spindle: discarding the saved session: the story no longer has the passage "${missing}"`);
14870
+ session = void 0;
14871
+ }
14764
14872
  if (session) useStoryStore.getState().loadFromPayload(session);
14765
14873
  emit("storyinit");
14766
14874
  if (!session) recordStoryInitState();
@@ -15254,6 +15362,27 @@ function addMacroTrigger(condition, options) {
15254
15362
  if (triggers.some((t) => t.macroKey === macroKey)) return;
15255
15363
  registerTrigger(condition, options, macroKey);
15256
15364
  }
15365
+ /**
15366
+ * The {watch} macro watchers now registered, for a save or the session:
15367
+ * watchers outlive the passage that declared them, so a game restored
15368
+ * where it stopped needs the ones its earlier passages registered (and
15369
+ * not the ones {unwatch} or `once` removed).
15370
+ */
15371
+ function savedMacroWatchers() {
15372
+ return triggers.filter((t) => t.macroKey !== void 0 && t.options).map((t) => ({
15373
+ condition: t.condition,
15374
+ options: { ...t.options }
15375
+ }));
15376
+ }
15377
+ /**
15378
+ * Make the {watch} macro watchers those of a loaded game: the ones of
15379
+ * `watchers` replace the registered ones (watchers added by Story.watch
15380
+ * code stay). The passage shown mounts its own {watch} macros again.
15381
+ */
15382
+ function restoreMacroWatchers(watchers) {
15383
+ triggers = triggers.filter((t) => t.macroKey === void 0);
15384
+ for (const { condition, options } of watchers) addMacroTrigger(condition, options);
15385
+ }
15257
15386
  function removeTrigger(name) {
15258
15387
  triggers = triggers.filter((t) => t.name !== name);
15259
15388
  }
@@ -15609,7 +15738,8 @@ function persistSession(get) {
15609
15738
  historyIndex,
15610
15739
  visitCounts,
15611
15740
  renderCounts,
15612
- prng: snapshotPRNG()
15741
+ prng: snapshotPRNG(),
15742
+ watchers: savedMacroWatchers()
15613
15743
  });
15614
15744
  }
15615
15745
  /** What the page shows before a session write error (see RuntimeErrors). */
@@ -15994,6 +16124,20 @@ function restorePRNGFrom(prng) {
15994
16124
  function restorePRNGFromMoment(moment) {
15995
16125
  if (moment) restorePRNGFrom(moment.prng);
15996
16126
  }
16127
+ /**
16128
+ * The first passage of `payload` (its current passage, then its history)
16129
+ * that `storyData` does not have, as when the story was updated since the
16130
+ * save; undefined if the story has them all (or is not loaded).
16131
+ */
16132
+ function missingPassage(storyData, payload) {
16133
+ if (!storyData) return void 0;
16134
+ return [payload.passage, ...payload.history.map((m) => m.passage)].find((name) => !storyData.passages.has(name));
16135
+ }
16136
+ /** Throw if `payload` refers to a passage `storyData` does not have. */
16137
+ function assertPassagesExist(storyData, payload) {
16138
+ const missing = missingPassage(storyData, payload);
16139
+ if (missing !== void 0) throw new Error(`The save refers to the passage "${missing}", which this version of the story does not have`);
16140
+ }
15997
16141
  var NAMESPACE_KEYS = [
15998
16142
  "variables",
15999
16143
  "temporary",
@@ -16336,7 +16480,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
16336
16480
  state.loadError = null;
16337
16481
  });
16338
16482
  const previous = resolvePlaythroughId();
16339
- const read = loadSlotSave(storyData.ifid, slot);
16483
+ const read = loadSlotSave(storyData.ifid, slot, (payload) => assertPassagesExist(storyData, payload));
16340
16484
  const switched = switchToLookedUpPlaythrough(Promise.all([previous, read.catch(() => void 0)]).then(([prev, loaded]) => loaded?.playthroughId || prev));
16341
16485
  const replacement = ++stateReplacementsIssued;
16342
16486
  return reported(read.then(async (loaded) => {
@@ -16421,7 +16565,8 @@ var useStoryStore = create()(storyStore((set, get) => ({
16421
16565
  historyIndex,
16422
16566
  visitCounts: { ...visitCounts },
16423
16567
  renderCounts: { ...renderCounts },
16424
- prng: snapshotPRNG()
16568
+ prng: snapshotPRNG(),
16569
+ watchers: savedMacroWatchers()
16425
16570
  };
16426
16571
  },
16427
16572
  beginSave: () => {
@@ -16433,12 +16578,14 @@ var useStoryStore = create()(storyStore((set, get) => ({
16433
16578
  };
16434
16579
  },
16435
16580
  loadFromPayload: (payload, slot, playthroughId) => {
16436
- const replacement = slotLoadApplying ?? ++stateReplacementsIssued;
16581
+ const applying = slotLoadApplying;
16437
16582
  slotLoadApplying = null;
16583
+ const replacement = applying ?? ++stateReplacementsIssued;
16438
16584
  if (payload.history.length === 0) {
16439
16585
  console.warn("loadFromPayload: rejecting payload with empty history");
16440
16586
  return;
16441
16587
  }
16588
+ assertPassagesExist(get().storyData, payload);
16442
16589
  latestStateApplied = replacement;
16443
16590
  emit("beforeload", slot);
16444
16591
  const ifid = get().storyData?.ifid;
@@ -16464,6 +16611,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
16464
16611
  state.temporary = createNamespace();
16465
16612
  state.transient = createNamespace(deepClone(get().transientDefaults));
16466
16613
  });
16614
+ if (payload.watchers) restoreMacroWatchers(payload.watchers);
16467
16615
  reinitTriggerState();
16468
16616
  lastNavigationVars = reconstructVarsAt(get().historyIndex);
16469
16617
  restorePRNGFrom(entry?.prng !== void 0 ? entry.prng : payload.prng);
@@ -16646,7 +16794,7 @@ function PassageDialog({ passageName, fallbackMarkup, panelClass, onClose, dismi
16646
16794
  }, []);
16647
16795
  const handleBackdrop = (e) => {
16648
16796
  if (!dismissible) return;
16649
- if (e.target.classList.contains("dialog-overlay")) stableOnClose();
16797
+ if (e.target === e.currentTarget) stableOnClose();
16650
16798
  };
16651
16799
  const cls = panelClass ? `dialog-panel ${panelClass}` : "dialog-panel";
16652
16800
  return /* @__PURE__ */ jsx(DialogCloseContext.Provider, {
@@ -16661,6 +16809,7 @@ function PassageDialog({ passageName, fallbackMarkup, panelClass, onClose, dismi
16661
16809
  "aria-modal": "true",
16662
16810
  tabIndex: -1,
16663
16811
  children: [showCloseButton && /* @__PURE__ */ jsx("button", {
16812
+ type: "button",
16664
16813
  class: "dialog-close",
16665
16814
  "aria-label": "Close",
16666
16815
  onClick: stableOnClose,
@@ -19086,7 +19235,7 @@ function keyOf(val, ancestors) {
19086
19235
  const depth = ancestors.indexOf(obj);
19087
19236
  if (depth !== -1) return `<cycle ${ancestors.length - depth}>`;
19088
19237
  if (val instanceof Date) return `Date(${val.getTime()})`;
19089
- if (val instanceof RegExp) return `RegExp(${String(val)})`;
19238
+ if (val instanceof RegExp) return `RegExp(${String(val)}@${val.lastIndex})`;
19090
19239
  ancestors.push(obj);
19091
19240
  try {
19092
19241
  if (Array.isArray(val)) return `[${Array.from(val, (v) => keyOf(v, ancestors)).join(",")}]`;
@@ -19201,15 +19350,7 @@ defineMacro({
19201
19350
  block: true,
19202
19351
  render({ children = [] }, ctx) {
19203
19352
  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;
19353
+ return useRunOnce("do", "", () => ctx.mutate(code));
19213
19354
  }
19214
19355
  });
19215
19356
  //#endregion
@@ -19272,11 +19413,13 @@ defineMacro({
19272
19413
  });
19273
19414
  //#endregion
19274
19415
  //#region src/components/macros/detached-body.tsx
19416
+ var frozenAlways = () => true;
19275
19417
  /**
19276
19418
  * Return a function that runs a macro body once, outside the passage tree:
19277
19419
  * it renders `children` into a detached node, so the body's macros ({set},
19278
19420
  * {if}, {goto}, ...) fire their side effects through the normal Preact
19279
- * pipeline. Used by {button} and {link} on click. The body stays mounted
19421
+ * pipeline. Used by {button} and {link} on click. The body's state is frozen
19422
+ * once it has rendered, so it runs once per click. It stays mounted
19280
19423
  * until the owning macro unmounts (its passage changes, say), so work it
19281
19424
  * starts in an effect, such as the timer of a {timed} or {repeat}, can
19282
19425
  * finish; the passage leaving cancels it.
@@ -19307,24 +19450,27 @@ function useDetachedBody() {
19307
19450
  const run = (children) => {
19308
19451
  const locals = liveLocalsView(updater.getValues);
19309
19452
  const container = document.createElement("div");
19310
- render(/* @__PURE__ */ jsx(LocalsUpdateContext.Provider, {
19311
- value: updater,
19312
- children: /* @__PURE__ */ jsx(LocalsValuesContext.Provider, {
19313
- value: locals,
19314
- children: /* @__PURE__ */ jsx(NobrContext.Provider, {
19315
- value: nobr,
19316
- children: /* @__PURE__ */ jsx(InlineContext.Provider, {
19317
- value: inline,
19318
- children: /* @__PURE__ */ jsx(WidgetChildrenContext.Provider, {
19319
- value: widgetChildren,
19320
- children: /* @__PURE__ */ jsx(RepeatContext.Provider, {
19321
- value: repeat,
19322
- children: /* @__PURE__ */ jsx(DialogCloseContext.Provider, {
19323
- value: closeDialog,
19324
- children: renderNodes(children, {
19325
- nobr,
19326
- inline,
19327
- locals
19453
+ render(/* @__PURE__ */ jsx(FrozenStateContext.Provider, {
19454
+ value: frozenAlways,
19455
+ children: /* @__PURE__ */ jsx(LocalsUpdateContext.Provider, {
19456
+ value: updater,
19457
+ children: /* @__PURE__ */ jsx(LocalsValuesContext.Provider, {
19458
+ value: locals,
19459
+ children: /* @__PURE__ */ jsx(NobrContext.Provider, {
19460
+ value: nobr,
19461
+ children: /* @__PURE__ */ jsx(InlineContext.Provider, {
19462
+ value: inline,
19463
+ children: /* @__PURE__ */ jsx(WidgetChildrenContext.Provider, {
19464
+ value: widgetChildren,
19465
+ children: /* @__PURE__ */ jsx(RepeatContext.Provider, {
19466
+ value: repeat,
19467
+ children: /* @__PURE__ */ jsx(DialogCloseContext.Provider, {
19468
+ value: closeDialog,
19469
+ children: renderNodes(children, {
19470
+ nobr,
19471
+ inline,
19472
+ locals
19473
+ })
19328
19474
  })
19329
19475
  })
19330
19476
  })
@@ -19361,6 +19507,7 @@ defineMacro({
19361
19507
  perform: handleClick
19362
19508
  });
19363
19509
  return /* @__PURE__ */ jsx("button", {
19510
+ type: "button",
19364
19511
  id: ctx.id,
19365
19512
  class: ctx.cls,
19366
19513
  onClick: handleClick,
@@ -19517,15 +19664,52 @@ defineMacro({
19517
19664
  });
19518
19665
  //#endregion
19519
19666
  //#region src/components/macros/Type.tsx
19667
+ /**
19668
+ * The clip-path that shows the first `count` characters of `root`'s text in
19669
+ * reading order: the boxes the browser laid out for them, each wrapped line
19670
+ * as far as its last revealed character, as one path in the coordinates of
19671
+ * `inner`'s box. Undefined where there is no layout to measure.
19672
+ */
19673
+ function revealedPath(root, inner, count) {
19674
+ const range = document.createRange();
19675
+ if (!range.getClientRects) return void 0;
19676
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
19677
+ let left = count;
19678
+ let end;
19679
+ for (let n = walker.nextNode(); n && left > 0; n = walker.nextNode()) {
19680
+ const length = n.nodeValue?.length ?? 0;
19681
+ end = {
19682
+ node: n,
19683
+ offset: Math.min(left, length)
19684
+ };
19685
+ left -= length;
19686
+ }
19687
+ if (!end) return "inset(0 100% 0 0)";
19688
+ range.setStart(root, 0);
19689
+ range.setEnd(end.node, end.offset);
19690
+ const rects = range.getClientRects();
19691
+ if (rects.length === 0) return void 0;
19692
+ const origin = inner.getBoundingClientRect();
19693
+ let path = "";
19694
+ for (const r of Array.from(rects)) {
19695
+ if (r.width === 0 && r.height === 0) continue;
19696
+ const x = (r.left - origin.left).toFixed(2);
19697
+ const y = (r.top - origin.top).toFixed(2);
19698
+ path += `M${x} ${y}h${r.width.toFixed(2)}v${r.height.toFixed(2)}h-${r.width.toFixed(2)}z`;
19699
+ }
19700
+ return path ? `path("${path}")` : void 0;
19701
+ }
19520
19702
  defineMacro({
19521
19703
  name: "type",
19522
19704
  block: true,
19523
19705
  interpolate: true,
19524
19706
  parameters: [DELAY_PARAMETER],
19525
19707
  render({ children = [] }, ctx) {
19526
- const { useState, useEffect, useRef } = ctx.hooks;
19708
+ const { useState, useEffect, useLayoutEffect, useRef } = ctx.hooks;
19527
19709
  const speed = ctx.args.delay ?? 0;
19528
19710
  const containerRef = useRef(null);
19711
+ const innerRef = useRef(null);
19712
+ const [clip, setClip] = useState();
19529
19713
  const [totalChars, setTotalChars] = useState(0);
19530
19714
  const [visibleChars, setVisibleChars] = useState(0);
19531
19715
  const visibleCharsRef = useRef(0);
@@ -19548,6 +19732,22 @@ defineMacro({
19548
19732
  return () => clearInterval(timer);
19549
19733
  }, [totalChars, speed]);
19550
19734
  const done = visibleChars >= totalChars && totalChars > 0;
19735
+ useLayoutEffect(() => {
19736
+ const root = containerRef.current;
19737
+ const inner = innerRef.current;
19738
+ if (!root || !inner || totalChars === 0 || done) {
19739
+ setClip(void 0);
19740
+ return;
19741
+ }
19742
+ const measure = () => setClip(revealedPath(root, inner, visibleChars));
19743
+ measure();
19744
+ window.addEventListener("resize", measure);
19745
+ return () => window.removeEventListener("resize", measure);
19746
+ }, [
19747
+ visibleChars,
19748
+ totalChars,
19749
+ done
19750
+ ]);
19551
19751
  const cls = [
19552
19752
  "macro-type",
19553
19753
  done ? "macro-type-done" : "",
@@ -19557,13 +19757,13 @@ defineMacro({
19557
19757
  id: ctx.id,
19558
19758
  class: cls,
19559
19759
  ref: containerRef,
19560
- style: { clipPath: totalChars > 0 && !done ? void 0 : void 0 },
19561
19760
  children: [/* @__PURE__ */ jsx("span", {
19761
+ ref: innerRef,
19562
19762
  class: "macro-type-inner",
19563
19763
  style: {
19564
19764
  display: "inline",
19565
19765
  visibility: totalChars === 0 ? "hidden" : "visible",
19566
- clipPath: totalChars > 0 && !done ? `inset(0 ${(totalChars - visibleChars) / totalChars * 100}% 0 0)` : void 0
19766
+ clipPath: clip
19567
19767
  },
19568
19768
  children: ctx.renderInlineNodes(children)
19569
19769
  }), !done && totalChars > 0 && /* @__PURE__ */ jsx("span", { class: "macro-type-cursor" })]
@@ -19611,6 +19811,7 @@ function defineMenubarAction(config) {
19611
19811
  perform: config.dialog ? () => setDialogOpen(true) : setup.perform
19612
19812
  });
19613
19813
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
19814
+ type: "button",
19614
19815
  id: ctx.id,
19615
19816
  class: cls,
19616
19817
  onClick: perform,
@@ -19720,6 +19921,7 @@ defineMacro({
19720
19921
  const label = ctx.resolve(ctx.args.label ?? "");
19721
19922
  const passageName = ctx.collectText(children).trim().replace(/^["']|["']$/g, "");
19722
19923
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("button", {
19924
+ type: "button",
19723
19925
  id: ctx.id,
19724
19926
  class: ctx.cls,
19725
19927
  onClick: () => setOpen(true),
@@ -19766,9 +19968,12 @@ function SaveManagerContent() {
19766
19968
  const closeDialog = useContext(DialogCloseContext);
19767
19969
  const { storyData, playthroughId, beginSave, loadFromPayload } = useStoryFields("storyData", "playthroughId", "beginSave", "loadFromPayload");
19768
19970
  const ifid = storyData?.ifid ?? "";
19971
+ const [temporary, setTemporary] = useState(false);
19769
19972
  const refresh = useCallback(async () => {
19770
19973
  if (!ifid) return;
19771
- setGroups(await getSavesGrouped(ifid));
19974
+ const data = await getSavesGrouped(ifid);
19975
+ setTemporary(getBackendType() === "memory");
19976
+ setGroups(data);
19772
19977
  setLoading(false);
19773
19978
  }, [ifid]);
19774
19979
  useEffect(() => {
@@ -19815,7 +20020,7 @@ function SaveManagerContent() {
19815
20020
  await saveWithHooks(void 0, void 0, beginSave, async (payload) => {
19816
20021
  return createSave(ifid, playthrough, payload);
19817
20022
  });
19818
- showStatus("Save created");
20023
+ showStatus(temporary ? "Save created (temporary)" : "Save created");
19819
20024
  await refresh();
19820
20025
  } catch {
19821
20026
  showStatus("Failed to create save", "error");
@@ -19931,10 +20136,12 @@ function SaveManagerContent() {
19931
20136
  children: /* @__PURE__ */ jsxs("div", {
19932
20137
  class: "saves-mode-toggle",
19933
20138
  children: [/* @__PURE__ */ jsx("button", {
20139
+ type: "button",
19934
20140
  class: mode === "save" ? "active" : "",
19935
20141
  onClick: () => setMode("save"),
19936
20142
  children: "Save"
19937
20143
  }), /* @__PURE__ */ jsx("button", {
20144
+ type: "button",
19938
20145
  class: mode === "load" ? "active" : "",
19939
20146
  onClick: () => setMode("load"),
19940
20147
  children: "Load"
@@ -19942,9 +20149,15 @@ function SaveManagerContent() {
19942
20149
  })
19943
20150
  })
19944
20151
  }),
20152
+ temporary && /* @__PURE__ */ jsx("div", {
20153
+ class: "saves-notice",
20154
+ role: "status",
20155
+ 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."
20156
+ }),
19945
20157
  /* @__PURE__ */ jsxs("div", {
19946
20158
  class: "saves-toolbar",
19947
20159
  children: [/* @__PURE__ */ jsx("button", {
20160
+ type: "button",
19948
20161
  class: "saves-toolbar-button",
19949
20162
  onClick: handleImport,
19950
20163
  children: "Import"
@@ -20012,25 +20225,30 @@ function SaveManagerContent() {
20012
20225
  class: "save-slot-actions",
20013
20226
  children: [
20014
20227
  mode === "save" ? /* @__PURE__ */ jsx("button", {
20228
+ type: "button",
20015
20229
  class: "save-slot-action primary",
20016
20230
  onClick: () => handleOverwrite(save.meta.id),
20017
20231
  children: "Save Here"
20018
20232
  }) : /* @__PURE__ */ jsx("button", {
20233
+ type: "button",
20019
20234
  class: "save-slot-action primary",
20020
20235
  onClick: () => handleLoad(save),
20021
20236
  children: "Load"
20022
20237
  }),
20023
20238
  /* @__PURE__ */ jsx("button", {
20239
+ type: "button",
20024
20240
  class: "save-slot-action",
20025
20241
  onClick: () => handleRenameStart(save),
20026
20242
  children: "Rename"
20027
20243
  }),
20028
20244
  /* @__PURE__ */ jsx("button", {
20245
+ type: "button",
20029
20246
  class: "save-slot-action",
20030
20247
  onClick: () => handleExport(save.meta.id),
20031
20248
  children: "Export"
20032
20249
  }),
20033
20250
  /* @__PURE__ */ jsx("button", {
20251
+ type: "button",
20034
20252
  class: "save-slot-action danger",
20035
20253
  onClick: () => handleDelete(save.meta.id),
20036
20254
  children: "Delete"
@@ -20038,6 +20256,7 @@ function SaveManagerContent() {
20038
20256
  ]
20039
20257
  })]
20040
20258
  }, save.meta.id)), mode === "save" && isCurrentPt && /* @__PURE__ */ jsx("button", {
20259
+ type: "button",
20041
20260
  class: "save-slot-new",
20042
20261
  onClick: handleNewSave,
20043
20262
  children: "+ New Save"
@@ -20049,6 +20268,7 @@ function SaveManagerContent() {
20049
20268
  children: /* @__PURE__ */ jsx("div", {
20050
20269
  class: "playthrough-saves",
20051
20270
  children: /* @__PURE__ */ jsx("button", {
20271
+ type: "button",
20052
20272
  class: "save-slot-new",
20053
20273
  onClick: handleNewSave,
20054
20274
  children: "+ New Save"
@@ -20277,13 +20497,19 @@ function defineInputMacro(name, type, toValue = (input) => input !== void 0 ? St
20277
20497
  label: placeholder || ctx.varName,
20278
20498
  perform: (v) => ctx.setValue(toValue(v))
20279
20499
  });
20500
+ const typed = ctx.hooks.useRef(null);
20501
+ const shown = typed.current !== null && toValue(typed.current) === ctx.value && type === "number" ? typed.current : display(ctx.value);
20280
20502
  return h(type ? "input" : "textarea", {
20281
20503
  type,
20282
20504
  id: ctx.id,
20283
20505
  class: ctx.cls,
20284
- value: display(ctx.value),
20506
+ value: shown,
20285
20507
  placeholder,
20286
- onInput: (e) => ctx.setValue(toValue(e.target.value))
20508
+ onInput: (e) => {
20509
+ const text = e.target.value;
20510
+ typed.current = text;
20511
+ ctx.setValue(toValue(text));
20512
+ }
20287
20513
  });
20288
20514
  }
20289
20515
  });
@@ -20420,6 +20646,7 @@ defineMacro({
20420
20646
  }
20421
20647
  });
20422
20648
  return /* @__PURE__ */ jsx("button", {
20649
+ type: "button",
20423
20650
  id: ctx.id,
20424
20651
  class: ctx.cls,
20425
20652
  onClick: handleClick,
@@ -20594,7 +20821,7 @@ function resolveTransition(targetTags, nextTransition, storeDefault) {
20594
20821
  /**
20595
20822
  * Create a snapshot clone of a .passage element for outgoing transitions.
20596
20823
  * - Strips `id` attributes from the clone tree
20597
- * - Disables interaction (pointer-events, user-select)
20824
+ * - Disables interaction (pointer-events, user-select, inert, aria-hidden)
20598
20825
  * - Pauses/mutes any <audio>/<video>
20599
20826
  * - Adds `.passage-snapshot` class and `data-transition` attribute
20600
20827
  */
@@ -20605,6 +20832,8 @@ function createSnapshot(passageEl, transitionType) {
20605
20832
  for (let i = 0; i < idEls.length; i++) idEls[i].removeAttribute("id");
20606
20833
  clone.style.pointerEvents = "none";
20607
20834
  clone.style.userSelect = "none";
20835
+ clone.setAttribute("inert", "");
20836
+ clone.setAttribute("aria-hidden", "true");
20608
20837
  const mediaEls = clone.querySelectorAll("audio, video");
20609
20838
  for (let i = 0; i < mediaEls.length; i++) {
20610
20839
  const media = mediaEls[i];
@@ -20672,6 +20901,9 @@ defineMacro({
20672
20901
  });
20673
20902
  const resolvedTypeRef = useRef("fade");
20674
20903
  const prevHistoryLenRef = useRef(0);
20904
+ const displayedId = displayed.id;
20905
+ const isOutgoing = useCallback((state) => state.navigationId !== displayedId, [displayedId]);
20906
+ const lastElement = useRef(null);
20675
20907
  const containerRef = useRef(null);
20676
20908
  const timeoutsRef = useRef([]);
20677
20909
  /** Cancel any in-progress transition. */
@@ -20740,6 +20972,16 @@ defineMacro({
20740
20972
  cleanupSnapshots(containerEl);
20741
20973
  }, config.duration)];
20742
20974
  }, [navigationId]);
20975
+ const shownId = displayedPassage && !renderDeferred ? displayed.id : null;
20976
+ const lastShownId = useRef(shownId);
20977
+ useLayoutEffect(() => {
20978
+ if (shownId === null) return;
20979
+ const previous = lastShownId.current;
20980
+ lastShownId.current = shownId;
20981
+ if (previous === null || previous === shownId) return;
20982
+ const el = containerRef.current?.querySelector(".passage");
20983
+ if (el && el.getBoundingClientRect().top < 0) el.scrollIntoView?.({ block: "start" });
20984
+ }, [shownId]);
20743
20985
  const effectivePassage = renderDeferred ? storyData?.passages.get("StoryLoading") ?? null : displayedPassage ? storyData?.passages.get(displayedPassage) : null;
20744
20986
  if (!effectivePassage && displayedPassage && !renderDeferred) return /* @__PURE__ */ jsxs("div", {
20745
20987
  class: "error",
@@ -20750,6 +20992,12 @@ defineMacro({
20750
20992
  ]
20751
20993
  });
20752
20994
  const readyPassage = storyData?.passages.get("PassageReady");
20995
+ const passageElement = !renderDeferred && displayed.id !== navigationId && lastElement.current ? lastElement.current : effectivePassage && /* @__PURE__ */ jsx(Passage, {
20996
+ passage: effectivePassage,
20997
+ dataTransition: renderDeferred ? "none" : resolvedTypeRef.current,
20998
+ navigationId: renderDeferred ? void 0 : displayed.id
20999
+ }, renderDeferred ? "loading" : `nav-${displayed.id}`);
21000
+ lastElement.current = passageElement || null;
20753
21001
  return /* @__PURE__ */ jsxs("div", {
20754
21002
  id: ctx.id ?? "story",
20755
21003
  class: ctx.className ?? "story",
@@ -20759,11 +21007,10 @@ defineMacro({
20759
21007
  }, `ready-${navigationId}`), /* @__PURE__ */ jsx("div", {
20760
21008
  class: "passage-container",
20761
21009
  ref: containerRef,
20762
- children: effectivePassage && /* @__PURE__ */ jsx(Passage, {
20763
- passage: effectivePassage,
20764
- dataTransition: renderDeferred ? "none" : resolvedTypeRef.current,
20765
- navigationId: renderDeferred ? void 0 : displayed.id
20766
- }, renderDeferred ? "loading" : `nav-${displayed.id}`)
21010
+ children: passageElement && /* @__PURE__ */ jsx(FrozenStateContext.Provider, {
21011
+ value: isOutgoing,
21012
+ children: passageElement
21013
+ })
20767
21014
  })]
20768
21015
  });
20769
21016
  }
@@ -20843,7 +21090,7 @@ defineMacro({
20843
21090
  });
20844
21091
  //#endregion
20845
21092
  //#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";
21093
+ 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
21094
  //#endregion
20848
21095
  //#region src/index.tsx
20849
21096
  function renderErrors(root, errors) {