@rohal12/spindle 0.59.7 → 0.59.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10096,18 +10096,18 @@ function historyQueries(visitCounts, renderCounts) {
10096
10096
  };
10097
10097
  }
10098
10098
  var cachedFns = null;
10099
- var cachedVisitCounts = null;
10100
- var cachedRenderCounts = null;
10099
+ /**
10100
+ * The functions compiled code calls. The history queries read the counters
10101
+ * when called, not when the functions were built: mutation code that
10102
+ * navigates (`Story.goto()`) goes on with the counters of the new state,
10103
+ * as the Story API does.
10104
+ */
10101
10105
  function buildExpressionFns() {
10102
- const { visitCounts, renderCounts } = useStoryStore.getState();
10103
- if (cachedFns && cachedVisitCounts === visitCounts && cachedRenderCounts === renderCounts) return cachedFns;
10104
- cachedFns = {
10105
- ...historyQueries(() => visitCounts, () => renderCounts),
10106
+ cachedFns ??= {
10107
+ ...historyQueries(() => useStoryStore.getState().visitCounts, () => useStoryStore.getState().renderCounts),
10106
10108
  random,
10107
10109
  randomInt
10108
10110
  };
10109
- cachedVisitCounts = visitCounts;
10110
- cachedRenderCounts = renderCounts;
10111
10111
  return cachedFns;
10112
10112
  }
10113
10113
  /**
@@ -13176,24 +13176,28 @@ function createIDBBackend() {
13176
13176
  }
13177
13177
  var _backend = null;
13178
13178
  async function detectBackend() {
13179
- if (typeof indexedDB !== "undefined") try {
13180
- await new Promise((resolve, reject) => {
13181
- const req = indexedDB.open("__spindle_probe__", 1);
13182
- req.onsuccess = () => {
13183
- req.result.close();
13184
- indexedDB.deleteDatabase("__spindle_probe__");
13185
- resolve();
13186
- };
13187
- req.onerror = () => reject(req.error);
13188
- });
13189
- return createIDBBackend();
13179
+ try {
13180
+ if (typeof indexedDB !== "undefined") {
13181
+ await new Promise((resolve, reject) => {
13182
+ const req = indexedDB.open("__spindle_probe__", 1);
13183
+ req.onsuccess = () => {
13184
+ req.result.close();
13185
+ indexedDB.deleteDatabase("__spindle_probe__");
13186
+ resolve();
13187
+ };
13188
+ req.onerror = () => reject(req.error);
13189
+ });
13190
+ return createIDBBackend();
13191
+ }
13190
13192
  } catch {}
13191
- if (typeof localStorage !== "undefined") try {
13192
- const probeKey = "__spindle_probe__";
13193
- localStorage.setItem(probeKey, "1");
13194
- const val = localStorage.getItem(probeKey);
13195
- localStorage.removeItem(probeKey);
13196
- if (val === "1") return createLocalStorageBackend();
13193
+ try {
13194
+ if (typeof localStorage !== "undefined") {
13195
+ const probeKey = "__spindle_probe__";
13196
+ localStorage.setItem(probeKey, "1");
13197
+ const val = localStorage.getItem(probeKey);
13198
+ localStorage.removeItem(probeKey);
13199
+ if (val === "1") return createLocalStorageBackend();
13200
+ }
13197
13201
  } catch {}
13198
13202
  return createMemoryBackend();
13199
13203
  }
@@ -14463,7 +14467,7 @@ function toSaveInfo(record, slot) {
14463
14467
  * (settled by operations issued before this call), so the save takes its
14464
14468
  * place in the order of operations when it is called.
14465
14469
  */
14466
- function quickSave(ifid, playthroughId, payload, slot, custom) {
14470
+ function quickSave$1(ifid, playthroughId, payload, slot, custom) {
14467
14471
  return inOrder(async () => {
14468
14472
  const ptId = await playthroughId;
14469
14473
  const existingId = await slotSaveId(ifid, slot);
@@ -14549,12 +14553,18 @@ var SESSION_KEY_PREFIX = "spindle.session.";
14549
14553
  * encodePayload). Throws, like a save, when the state holds a value a save
14550
14554
  * cannot (a function, an instance of an unregistered class, a unique
14551
14555
  * symbol); the session then keeps its previous copy.
14556
+ *
14557
+ * A storage that is unavailable or full is returned, not thrown: the session
14558
+ * keeps its previous copy then too, but the story can go on, so the caller
14559
+ * decides how to tell the player.
14552
14560
  */
14553
14561
  function saveSession(ifid, payload) {
14554
14562
  const text = JSON.stringify(encodePayload(payload));
14555
14563
  try {
14556
14564
  sessionStorage.setItem(`${SESSION_KEY_PREFIX}${ifid}`, text);
14557
- } catch {}
14565
+ } catch (error) {
14566
+ return { error };
14567
+ }
14558
14568
  }
14559
14569
  /**
14560
14570
  * The live payload of the stored session, if there is one that loads. A
@@ -14671,14 +14681,14 @@ var clearAllData = queued(clearAllDataNow);
14671
14681
  async function clearAllDataNow() {
14672
14682
  await (await getBackend()).destroy();
14673
14683
  resetBackend();
14674
- if (typeof sessionStorage !== "undefined") {
14684
+ try {
14675
14685
  const toRemove = [];
14676
14686
  for (let i = 0; i < sessionStorage.length; i++) {
14677
14687
  const key = sessionStorage.key(i);
14678
14688
  if (key?.startsWith("spindle.session.")) toRemove.push(key);
14679
14689
  }
14680
14690
  for (const key of toRemove) sessionStorage.removeItem(key);
14681
- }
14691
+ } catch {}
14682
14692
  }
14683
14693
  /**
14684
14694
  * Delete a playthrough with its saves, emptying the slots that held them.
@@ -15584,7 +15594,7 @@ var sessionMoments = [];
15584
15594
  */
15585
15595
  function persistSession(get) {
15586
15596
  const { storyData, currentPassage, variables, history, historyIndex, visitCounts, renderCounts } = get();
15587
- if (!storyData) return;
15597
+ if (!storyData) return void 0;
15588
15598
  if (sessionMoments.length > history.length) sessionMoments.length = history.length;
15589
15599
  if (sessionMoments.length < history.length) if (history.length - sessionMoments.length === 1) {
15590
15600
  const i = history.length - 1;
@@ -15592,7 +15602,7 @@ function persistSession(get) {
15592
15602
  } else forEachRecorded(history.length, (vars, i) => {
15593
15603
  if (i >= sessionMoments.length) sessionMoments[i] = savedMoment(history[i], vars);
15594
15604
  });
15595
- saveSession(storyData.ifid, {
15605
+ return saveSession(storyData.ifid, {
15596
15606
  passage: currentPassage,
15597
15607
  variables,
15598
15608
  history: sessionMoments,
@@ -15618,7 +15628,8 @@ var NAVIGATION_ERROR_CONTEXT = "The story could not go to another passage:";
15618
15628
  function persistSessionThen(get, after) {
15619
15629
  let failure;
15620
15630
  try {
15621
- persistSession(get);
15631
+ const storageFailure = persistSession(get);
15632
+ if (storageFailure) showRuntimeError(SESSION_ERROR_CONTEXT, storageFailure.error);
15622
15633
  } catch (error) {
15623
15634
  failure = { error };
15624
15635
  showRuntimeError(SESSION_ERROR_CONTEXT, error);
@@ -16312,7 +16323,7 @@ var useStoryStore = create()(storyStore((set, get) => ({
16312
16323
  set((state) => {
16313
16324
  state.saveError = null;
16314
16325
  });
16315
- await quickSave(storyData.ifid, playthrough, payload, slot, custom);
16326
+ await quickSave$1(storyData.ifid, playthrough, payload, slot, custom);
16316
16327
  recordKnownSave(set, slot, true);
16317
16328
  }), "spindle: failed to save", (err) => set((state) => {
16318
16329
  state.saveError = errorMessage(err, "Failed to save");
@@ -16525,8 +16536,19 @@ var FOCUSABLE = [
16525
16536
  "[contenteditable=\"true\"]",
16526
16537
  "[tabindex]:not([tabindex=\"-1\"])"
16527
16538
  ].join(",");
16539
+ /**
16540
+ * Whether CSS leaves `el` in the tab sequence: not `visibility: hidden`
16541
+ * (inherited, so the computed value covers ancestors) and not inside a
16542
+ * `display: none` element.
16543
+ */
16544
+ function isRendered(el) {
16545
+ const { visibility } = getComputedStyle(el);
16546
+ if (visibility === "hidden" || visibility === "collapse") return false;
16547
+ for (let e = el; e; e = e.parentElement) if (getComputedStyle(e).display === "none") return false;
16548
+ return true;
16549
+ }
16528
16550
  function focusables(root) {
16529
- return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el) => !el.hidden && !el.closest("[hidden], [inert]"));
16551
+ return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el) => !el.hidden && !el.closest("[hidden], [inert]") && isRendered(el));
16530
16552
  }
16531
16553
  /** Open modal dialogs, innermost last. Only the topmost handles keys. */
16532
16554
  var openModals = [];
@@ -16742,18 +16764,39 @@ function RuntimeErrors() {
16742
16764
  });
16743
16765
  }
16744
16766
  //#endregion
16767
+ //#region src/quick-actions.ts
16768
+ /**
16769
+ * QuickSave and QuickLoad as the built-in buttons and hotkeys perform them:
16770
+ * a failure is shown on the page (the Story API's save() and load() only
16771
+ * reject, for the code that awaits them).
16772
+ */
16773
+ async function quickSave() {
16774
+ try {
16775
+ await useStoryStore.getState().save();
16776
+ } catch (error) {
16777
+ showRuntimeError("The game could not be saved:", error);
16778
+ }
16779
+ }
16780
+ async function quickLoad() {
16781
+ try {
16782
+ await useStoryStore.getState().load();
16783
+ } catch (error) {
16784
+ showRuntimeError("The saved game could not be loaded:", error);
16785
+ }
16786
+ }
16787
+ //#endregion
16745
16788
  //#region src/components/App.tsx
16746
16789
  function App() {
16747
16790
  const { storyData, currentPassage, nobr } = useStoryFields("storyData", "currentPassage", "nobr");
16748
16791
  useEffect(() => {
16749
16792
  const onKeyDown = (e) => {
16750
- const { quickSaveKey, quickLoadKey, save, load } = useStoryStore.getState();
16793
+ const { quickSaveKey, quickLoadKey } = useStoryStore.getState();
16751
16794
  if (quickSaveKey !== null && e.key === quickSaveKey) {
16752
16795
  e.preventDefault();
16753
- save();
16796
+ quickSave();
16754
16797
  } else if (quickLoadKey !== null && e.key === quickLoadKey) {
16755
16798
  e.preventDefault();
16756
- load();
16799
+ quickLoad();
16757
16800
  }
16758
16801
  };
16759
16802
  document.addEventListener("keydown", onKeyDown);
@@ -16931,7 +16974,9 @@ function storageKey() {
16931
16974
  return `spindle.${useStoryStore.getState().storyData?.ifid || "unknown"}.settings`;
16932
16975
  }
16933
16976
  function persist() {
16934
- localStorage.setItem(storageKey(), JSON.stringify(values));
16977
+ try {
16978
+ localStorage.setItem(storageKey(), JSON.stringify(values));
16979
+ } catch {}
16935
16980
  }
16936
16981
  function loadFromStorage() {
16937
16982
  if (storageLoaded) return;
@@ -18215,7 +18260,13 @@ function widgetDef({ name = "", parameters = "" }) {
18215
18260
  function parseWidgetDef(rawArgs) {
18216
18261
  return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
18217
18262
  }
18218
- var BLOCK_WIDGET = /\{widget\s+["']?(\w+)["']?[^}]*\}([\s\S]*?)\{\/widget\}/g;
18263
+ /**
18264
+ * A definition: its arguments (read like a {widget} macro's, see
18265
+ * parseWidgetDef) and its body. Macro names are not case-sensitive.
18266
+ */
18267
+ var WIDGET_DEFINITION = /\{widget\s+([^}]*)\}([\s\S]*?)\{\/widget\}/gi;
18268
+ /** `{@children}`, also with selectors such as `{.highlight @children}`. */
18269
+ var CHILDREN_PLACEHOLDER = /\{[^{}]*@children\s*\}/;
18219
18270
  /**
18220
18271
  * The names of the block widgets (those whose body renders `{@children}`)
18221
18272
  * that StoryInit and the passages tagged `widget` define. They must be
@@ -18226,7 +18277,12 @@ function blockWidgetNames(passages) {
18226
18277
  const names = [];
18227
18278
  for (const passage of passages) {
18228
18279
  if (passage.name !== "StoryInit" && !passage.tags?.includes("widget")) continue;
18229
- for (const match of passage.content.matchAll(BLOCK_WIDGET)) if (/\{@children\}/.test(match[2])) names.push(match[1]);
18280
+ for (const match of passage.content.matchAll(WIDGET_DEFINITION)) {
18281
+ if (!CHILDREN_PLACEHOLDER.test(match[2])) continue;
18282
+ try {
18283
+ names.push(parseWidgetDef(match[1]).name);
18284
+ } catch {}
18285
+ }
18230
18286
  }
18231
18287
  return names;
18232
18288
  }
@@ -19220,7 +19276,10 @@ defineMacro({
19220
19276
  * Return a function that runs a macro body once, outside the passage tree:
19221
19277
  * it renders `children` into a detached node, so the body's macros ({set},
19222
19278
  * {if}, {goto}, ...) fire their side effects through the normal Preact
19223
- * pipeline, then unmounts it. Used by {button} and {link} on click.
19279
+ * pipeline. Used by {button} and {link} on click. The body stays mounted
19280
+ * until the owning macro unmounts (its passage changes, say), so work it
19281
+ * starts in an effect, such as the timer of a {timed} or {repeat}, can
19282
+ * finish; the passage leaving cancels it.
19224
19283
  *
19225
19284
  * A detached render starts with no contexts, so the hook captures the ones
19226
19285
  * the owning macro sees and provides them again: the locals scope (read
@@ -19241,6 +19300,10 @@ function useDetachedBody() {
19241
19300
  const widgetChildren = useContext(WidgetChildrenContext);
19242
19301
  const repeat = useContext(RepeatContext);
19243
19302
  const closeDialog = useContext(DialogCloseContext);
19303
+ const mounted = useRef([]);
19304
+ useEffect(() => () => {
19305
+ for (const container of mounted.current.splice(0)) render(null, container);
19306
+ }, []);
19244
19307
  const run = (children) => {
19245
19308
  const locals = liveLocalsView(updater.getValues);
19246
19309
  const container = document.createElement("div");
@@ -19270,7 +19333,7 @@ function useDetachedBody() {
19270
19333
  })
19271
19334
  })
19272
19335
  }), container);
19273
- render(null, container);
19336
+ mounted.current.push(container);
19274
19337
  };
19275
19338
  return (children) => runWithCommittedMutations(() => run(children));
19276
19339
  }
@@ -19587,9 +19650,9 @@ defineMenubarAction({
19587
19650
  label: "QuickSave",
19588
19651
  actionType: "save",
19589
19652
  setup: () => {
19590
- const { save, quickSaveKey } = useStoryFields("save", "quickSaveKey");
19653
+ const { quickSaveKey } = useStoryFields("quickSaveKey");
19591
19654
  return {
19592
- perform: save,
19655
+ perform: quickSave,
19593
19656
  title: hotkeyTitle("Quick Save", quickSaveKey)
19594
19657
  };
19595
19658
  }
@@ -19600,9 +19663,9 @@ defineMenubarAction({
19600
19663
  actionType: "load",
19601
19664
  confirm: "Load saved game? Current progress will be lost.",
19602
19665
  setup: () => {
19603
- const { load, hasSave, quickLoadKey } = useStoryFields("load", "hasSave", "quickLoadKey", "knownSaves");
19666
+ const { hasSave, quickLoadKey } = useStoryFields("hasSave", "quickLoadKey", "knownSaves");
19604
19667
  return {
19605
- perform: () => load(),
19668
+ perform: quickLoad,
19606
19669
  disabled: !hasSave(),
19607
19670
  title: hotkeyTitle("Quick Load", quickLoadKey)
19608
19671
  };
@@ -19696,6 +19759,7 @@ function SaveManagerContent() {
19696
19759
  const [status, setStatus] = useState(null);
19697
19760
  const [collapsed, setCollapsed] = useState(() => /* @__PURE__ */ new Set());
19698
19761
  const [renamingId, setRenamingId] = useState(null);
19762
+ const renameCancelled = useRef(false);
19699
19763
  const [renameValue, setRenameValue] = useState("");
19700
19764
  const renameInputRef = useRef(null);
19701
19765
  const fileInputRef = useRef(null);
@@ -19794,6 +19858,7 @@ function SaveManagerContent() {
19794
19858
  }
19795
19859
  };
19796
19860
  const handleRenameStart = (save) => {
19861
+ renameCancelled.current = false;
19797
19862
  setRenamingId(save.meta.id);
19798
19863
  setRenameValue(save.meta.title);
19799
19864
  };
@@ -19810,7 +19875,15 @@ function SaveManagerContent() {
19810
19875
  };
19811
19876
  const handleRenameKeyDown = (e) => {
19812
19877
  if (e.key === "Enter") handleRenameConfirm();
19813
- else if (e.key === "Escape") setRenamingId(null);
19878
+ else if (e.key === "Escape") {
19879
+ e.preventDefault();
19880
+ e.stopPropagation();
19881
+ renameCancelled.current = true;
19882
+ setRenamingId(null);
19883
+ }
19884
+ };
19885
+ const handleRenameBlur = () => {
19886
+ if (!renameCancelled.current) handleRenameConfirm();
19814
19887
  };
19815
19888
  const handleExport = async (saveId) => {
19816
19889
  try {
@@ -19927,7 +20000,7 @@ function SaveManagerContent() {
19927
20000
  value: renameValue,
19928
20001
  onInput: (e) => setRenameValue(e.target.value),
19929
20002
  onKeyDown: handleRenameKeyDown,
19930
- onBlur: handleRenameConfirm
20003
+ onBlur: handleRenameBlur
19931
20004
  }) : /* @__PURE__ */ jsx("div", {
19932
20005
  class: "save-slot-title",
19933
20006
  children: save.meta.title
@@ -20570,6 +20643,19 @@ function cleanupSnapshots(containerEl) {
20570
20643
  const snapshots = containerEl.querySelectorAll(".passage-snapshot");
20571
20644
  for (let i = 0; i < snapshots.length; i++) snapshots[i].remove();
20572
20645
  }
20646
+ /**
20647
+ * `config` for players who prefer reduced motion: the CSS shortens the
20648
+ * animations (see styles.css), so the waits that follow them shrink too,
20649
+ * leaving no empty passage area before the next passage.
20650
+ */
20651
+ function reduceMotion(config) {
20652
+ if (!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return config;
20653
+ return {
20654
+ ...config,
20655
+ duration: Math.min(config.duration, 10),
20656
+ pause: 0
20657
+ };
20658
+ }
20573
20659
  defineMacro({
20574
20660
  name: "passage",
20575
20661
  interpolate: true,
@@ -20617,7 +20703,7 @@ defineMacro({
20617
20703
  setDisplayedPassage(currentPassage);
20618
20704
  return;
20619
20705
  }
20620
- const config = resolveTransition(targetPassage.tags, next, transitionConfig);
20706
+ const config = reduceMotion(resolveTransition(targetPassage.tags, next, transitionConfig));
20621
20707
  resolvedTypeRef.current = config.type;
20622
20708
  const containerEl = containerRef.current;
20623
20709
  if (config.type === "none") {
@@ -20757,7 +20843,7 @@ defineMacro({
20757
20843
  });
20758
20844
  //#endregion
20759
20845
  //#region src/styles.css?inline
20760
- var styles_default = "*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\nhtml {\n font-size: 16px;\n -webkit-text-size-adjust: 100%;\n}\n\nbody {\n margin: 0;\n padding: 0;\n font-family: Georgia, 'Times New Roman', serif;\n line-height: 1.75;\n color: #e0e0e0;\n background-color: #1a1a2e;\n min-height: 100vh;\n}\n\ntw-storydata {\n display: none !important;\n}\n\n#story {\n max-width: 42em;\n margin: 0 auto;\n padding: 2em 1.5em;\n}\n\n/* Passage transitions */\n\n.passage-container {\n position: relative;\n}\n\n.passage-container--crossfading {\n display: grid;\n}\n\n.passage-container--crossfading > * {\n grid-area: 1 / 1;\n}\n\n.passage {\n animation: passage-fade-in var(--passage-in-duration, 0.3s) ease;\n}\n\n.passage[data-transition='none'] {\n animation: none;\n}\n\n.passage-snapshot {\n animation: passage-fade-out var(--passage-out-duration, 0.3s) ease forwards;\n pointer-events: none;\n user-select: none;\n}\n\n@keyframes passage-fade-in {\n from {\n opacity: 0;\n transform: translateY(8px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n@keyframes passage-fade-out {\n from {\n opacity: 1;\n transform: translateY(0);\n }\n to {\n opacity: 0;\n transform: translateY(-8px);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .passage,\n .passage-snapshot {\n animation-duration: 0.01s !important;\n }\n}\n\n.macro-button {\n background: rgba(100, 181, 246, 0.15);\n color: #64b5f6;\n border: 1px solid rgba(100, 181, 246, 0.4);\n border-radius: 4px;\n padding: 0.25em 0.75em;\n font: inherit;\n font-size: 0.95em;\n cursor: pointer;\n transition:\n background 0.2s,\n border-color 0.2s;\n}\n\n.macro-button:hover {\n background: rgba(100, 181, 246, 0.25);\n border-color: #90caf9;\n}\n\n.macro-button:active {\n background: rgba(100, 181, 246, 0.35);\n}\n\n.error {\n color: #ef5350;\n font-family: monospace;\n padding: 1em;\n border: 1px solid #ef5350;\n border-radius: 4px;\n background: rgba(239, 83, 80, 0.1);\n}\n\n/* Runtime errors shown on the page (see src/components/RuntimeErrors.tsx) */\n.spindle-error-banners {\n position: fixed;\n top: 0.75em;\n left: 50%;\n z-index: 300;\n display: flex;\n flex-direction: column;\n gap: 0.5em;\n width: min(42em, calc(100% - 1.5em));\n transform: translateX(-50%);\n}\n\n.spindle-error-banner {\n position: relative;\n padding: 0.75em 2.75em 0.75em 1em;\n border: 1px solid #ef5350;\n border-radius: 4px;\n background: #2b1a1e;\n color: #ffcdd2;\n font-size: 0.9em;\n line-height: 1.5;\n box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);\n}\n\n.spindle-error-banner-message {\n font-family: monospace;\n color: #ef9a9a;\n}\n\n.spindle-error-banner-dismiss {\n position: absolute;\n top: 0.5em;\n right: 0.5em;\n background: transparent;\n border: none;\n color: inherit;\n font-size: 1em;\n cursor: pointer;\n}\n\n.spindle-error-banner-dismiss:hover {\n color: #fff;\n}\n\n.loading {\n text-align: center;\n padding: 2em;\n opacity: 0.6;\n}\n\n/* Menu bar */\n\n.story-menubar {\n position: sticky;\n top: 0;\n z-index: 100;\n display: flex;\n align-items: center;\n gap: 0.5em;\n padding: 0.5em 1.5em;\n background: #16213e;\n border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n font-family: system-ui, sans-serif;\n font-size: 0.9em;\n}\n\n.story-title {\n flex: 1;\n font-weight: 600;\n color: #e0e0e0;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.menubar-button {\n background: transparent;\n color: #9e9e9e;\n border: 1px solid transparent;\n padding: 0.35em 0.7em;\n border-radius: 4px;\n cursor: pointer;\n font: inherit;\n transition:\n color 0.2s,\n background 0.2s;\n}\n\n.menubar-button:hover {\n color: #e0e0e0;\n background: rgba(255, 255, 255, 0.08);\n}\n\n.menubar-button:disabled {\n opacity: 0.4;\n cursor: default;\n}\n\n.menubar-button:disabled:hover {\n color: #9e9e9e;\n background: transparent;\n}\n\n/* Generic dialog */\n\n.dialog-overlay {\n position: fixed;\n inset: 0;\n z-index: 200;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.6);\n}\n\n.dialog-panel {\n background: #16213e;\n border: 1px solid rgba(255, 255, 255, 0.1);\n border-radius: 8px;\n width: 90%;\n max-width: 500px;\n max-height: 80vh;\n display: flex;\n flex-direction: column;\n color: #e0e0e0;\n font-family: system-ui, sans-serif;\n position: relative;\n}\n\n/* Focused programmatically when the dialog has no focusable content */\n.dialog-panel:focus {\n outline: none;\n}\n\n.dialog-saves {\n max-width: 550px;\n}\n\n.dialog-settings {\n max-width: 400px;\n}\n\n.dialog-close {\n position: absolute;\n top: 0.75em;\n right: 0.75em;\n z-index: 1;\n background: transparent;\n border: none;\n color: #9e9e9e;\n font-size: 1.1em;\n cursor: pointer;\n padding: 0.25em;\n line-height: 1;\n}\n\n.dialog-close:hover {\n color: #e0e0e0;\n}\n\n.dialog-body {\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n}\n\n/* Settings controls */\n\n.settings-controls {\n padding: 1em 1.25em;\n display: flex;\n flex-direction: column;\n gap: 1em;\n}\n\n.settings-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1em;\n}\n\n.settings-row select,\n.settings-row input[type='range'] {\n accent-color: #64b5f6;\n}\n\n.settings-row select {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n padding: 0.3em 0.5em;\n font: inherit;\n}\n\n.settings-row input[type='checkbox'] {\n accent-color: #64b5f6;\n width: 1.1em;\n height: 1.1em;\n}\n\n/* Save manager */\n\n.save-manager {\n display: flex;\n flex-direction: column;\n flex: 1;\n min-height: 0;\n}\n\n.saves-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 1em 1.25em;\n padding-right: 3em;\n border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n font-weight: 600;\n flex-shrink: 0;\n}\n\n.saves-header-left {\n display: flex;\n align-items: center;\n gap: 0.75em;\n}\n\n.saves-mode-toggle {\n display: flex;\n gap: 0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n overflow: hidden;\n}\n\n.saves-mode-toggle button {\n background: transparent;\n color: #9e9e9e;\n border: none;\n padding: 0.3em 0.8em;\n font: inherit;\n font-size: 0.85em;\n font-weight: 600;\n cursor: pointer;\n transition:\n background 0.2s,\n color 0.2s;\n}\n\n.saves-mode-toggle button.active {\n background: rgba(100, 181, 246, 0.2);\n color: #64b5f6;\n}\n\n.saves-mode-toggle button:hover:not(.active) {\n color: #e0e0e0;\n}\n\n.saves-body {\n padding: 0.75em 1.25em;\n overflow-y: auto;\n flex: 1;\n min-height: 0;\n}\n\n.saves-empty {\n text-align: center;\n padding: 2em 1em;\n opacity: 0.5;\n font-size: 0.9em;\n}\n\n.saves-toolbar {\n display: flex;\n gap: 0.5em;\n padding: 0.5em 1.25em;\n border-bottom: 1px solid rgba(255, 255, 255, 0.08);\n flex-shrink: 0;\n}\n\n.saves-toolbar-button {\n background: rgba(255, 255, 255, 0.05);\n color: #9e9e9e;\n border: 1px solid rgba(255, 255, 255, 0.1);\n border-radius: 4px;\n padding: 0.3em 0.7em;\n font: inherit;\n font-size: 0.8em;\n cursor: pointer;\n transition:\n background 0.2s,\n color 0.2s;\n}\n\n.saves-toolbar-button:hover {\n background: rgba(255, 255, 255, 0.1);\n color: #e0e0e0;\n}\n\n.playthrough-group {\n margin-bottom: 0.75em;\n}\n\n.playthrough-header {\n display: flex;\n align-items: center;\n gap: 0.5em;\n padding: 0.4em 0;\n cursor: pointer;\n user-select: none;\n font-size: 0.85em;\n color: #9e9e9e;\n transition: color 0.2s;\n}\n\n.playthrough-header:hover {\n color: #e0e0e0;\n}\n\n.playthrough-chevron {\n display: inline-block;\n font-size: 0.7em;\n transition: transform 0.2s;\n}\n\n.playthrough-chevron.open {\n transform: rotate(90deg);\n}\n\n.playthrough-label {\n font-weight: 600;\n}\n\n.playthrough-date {\n opacity: 0.6;\n font-size: 0.9em;\n}\n\n.playthrough-saves {\n display: flex;\n flex-direction: column;\n gap: 0.4em;\n padding-left: 0.25em;\n}\n\n.save-slot {\n display: flex;\n align-items: center;\n gap: 0.75em;\n padding: 0.5em 0.6em;\n background: rgba(255, 255, 255, 0.03);\n border: 1px solid rgba(255, 255, 255, 0.06);\n border-radius: 6px;\n transition: background 0.15s;\n}\n\n.save-slot:hover {\n background: rgba(255, 255, 255, 0.06);\n}\n\n.save-slot-info {\n flex: 1;\n min-width: 0;\n}\n\n.save-slot-title {\n font-weight: 500;\n font-size: 0.9em;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.save-slot-meta {\n font-size: 0.75em;\n color: #9e9e9e;\n display: flex;\n gap: 0.75em;\n}\n\n.save-slot-actions {\n display: flex;\n gap: 0.3em;\n flex-shrink: 0;\n}\n\n.save-slot-action {\n background: transparent;\n color: #9e9e9e;\n border: 1px solid transparent;\n border-radius: 3px;\n padding: 0.2em 0.5em;\n font: inherit;\n font-size: 0.75em;\n cursor: pointer;\n transition:\n color 0.2s,\n background 0.2s;\n white-space: nowrap;\n}\n\n.save-slot-action:hover {\n color: #e0e0e0;\n background: rgba(255, 255, 255, 0.08);\n}\n\n.save-slot-action.primary {\n color: #64b5f6;\n border-color: rgba(100, 181, 246, 0.3);\n}\n\n.save-slot-action.primary:hover {\n background: rgba(100, 181, 246, 0.15);\n}\n\n.save-slot-action.danger:hover {\n color: #ef5350;\n background: rgba(239, 83, 80, 0.1);\n}\n\n.save-slot-new {\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 0.5em;\n margin-top: 0.25em;\n border: 1px dashed rgba(255, 255, 255, 0.12);\n border-radius: 6px;\n color: #9e9e9e;\n font: inherit;\n font-size: 0.8em;\n background: transparent;\n cursor: pointer;\n width: 100%;\n transition:\n color 0.2s,\n border-color 0.2s,\n background 0.2s;\n}\n\n.save-slot-new:hover {\n color: #64b5f6;\n border-color: rgba(100, 181, 246, 0.3);\n background: rgba(100, 181, 246, 0.05);\n}\n\n.saves-status {\n padding: 0.5em 1.25em;\n font-size: 0.8em;\n color: #81c784;\n border-top: 1px solid rgba(255, 255, 255, 0.08);\n flex-shrink: 0;\n text-align: center;\n}\n\n.saves-status.error {\n color: #ef5350;\n border: none;\n border-radius: 0;\n padding: 0.5em 1.25em;\n font-family: inherit;\n background: transparent;\n}\n\n.save-rename-input {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(100, 181, 246, 0.4);\n border-radius: 3px;\n padding: 0.15em 0.4em;\n font: inherit;\n font-size: 0.9em;\n width: 100%;\n}\n\n.save-rename-input:focus {\n outline: none;\n border-color: #64b5f6;\n}\n\n/* Macro: link */\n\n.macro-link {\n color: #64b5f6;\n text-decoration: none;\n border-bottom: 1px solid rgba(100, 181, 246, 0.3);\n transition:\n color 0.2s,\n border-color 0.2s;\n cursor: pointer;\n}\n\n.macro-link:hover {\n color: #90caf9;\n border-bottom-color: #90caf9;\n}\n\n.macro-link:active {\n color: #42a5f5;\n}\n\n/* Macro: form inputs */\n\n.macro-textbox,\n.macro-numberbox,\n.macro-textarea {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n padding: 0.3em 0.5em;\n font: inherit;\n}\n\n.macro-textbox:focus,\n.macro-numberbox:focus,\n.macro-textarea:focus {\n outline: none;\n border-color: #64b5f6;\n}\n\n.macro-textarea {\n min-height: 3em;\n resize: vertical;\n}\n\n.macro-checkbox,\n.macro-radiobutton {\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n gap: 0.3em;\n}\n\n.macro-checkbox input,\n.macro-radiobutton input {\n accent-color: #64b5f6;\n}\n\n/* Macro: listbox */\n\n.macro-listbox {\n background: #1a1a2e;\n color: #e0e0e0;\n border: 1px solid rgba(255, 255, 255, 0.15);\n border-radius: 4px;\n padding: 0.3em 0.5em;\n font: inherit;\n}\n\n/* Macro: cycle */\n\n.macro-cycle {\n background: rgba(100, 181, 246, 0.15);\n color: #64b5f6;\n border: 1px solid rgba(100, 181, 246, 0.4);\n border-radius: 4px;\n padding: 0.25em 0.75em;\n font: inherit;\n cursor: pointer;\n transition:\n background 0.2s,\n border-color 0.2s;\n}\n\n.macro-cycle:hover {\n background: rgba(100, 181, 246, 0.25);\n border-color: #90caf9;\n}\n\n/* Macro: meter */\n\n.macro-meter {\n position: relative;\n width: 100%;\n height: 1.4em;\n background: #2a2a3e;\n border-radius: 0.3em;\n overflow: hidden;\n border: 1px solid #444;\n}\n\n.macro-meter-fill {\n height: 100%;\n background: #64b5f6;\n transition: width 0.3s ease;\n}\n\n.macro-meter-label {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 0.85em;\n color: #e0e0e0;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);\n pointer-events: none;\n}\n\n/* Markdown wrapper — invisible to layout */\n\n.md {\n display: contents;\n}\n\n/* Markdown block elements inside passages */\n\n.passage p {\n margin: 0.6em 0;\n}\n\n.passage p:first-child {\n margin-top: 0;\n}\n\n.passage p:last-child {\n margin-bottom: 0;\n}\n\n.passage h1,\n.passage h2,\n.passage h3,\n.passage h4,\n.passage h5,\n.passage h6 {\n margin: 1em 0 0.4em;\n line-height: 1.3;\n color: #f0f0f0;\n}\n\n.passage h1 {\n font-size: 1.8em;\n}\n.passage h2 {\n font-size: 1.5em;\n}\n.passage h3 {\n font-size: 1.25em;\n}\n.passage h4 {\n font-size: 1.1em;\n}\n.passage h5 {\n font-size: 1em;\n}\n.passage h6 {\n font-size: 0.9em;\n opacity: 0.8;\n}\n\n.passage code {\n font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;\n font-size: 0.9em;\n background: rgba(255, 255, 255, 0.08);\n padding: 0.15em 0.35em;\n border-radius: 3px;\n}\n\n.passage pre {\n background: rgba(0, 0, 0, 0.3);\n border: 1px solid rgba(255, 255, 255, 0.08);\n border-radius: 6px;\n padding: 1em;\n overflow-x: auto;\n margin: 0.8em 0;\n}\n\n.passage pre code {\n background: none;\n padding: 0;\n border-radius: 0;\n font-size: 0.85em;\n}\n\n.passage blockquote {\n border-left: 3px solid rgba(100, 181, 246, 0.5);\n margin: 0.8em 0;\n padding: 0.4em 1em;\n color: #b0b0b0;\n}\n\n.passage blockquote p {\n margin: 0.3em 0;\n}\n\n.passage ul,\n.passage ol {\n margin: 0.6em 0;\n padding-left: 1.8em;\n}\n\n.passage li {\n margin: 0.2em 0;\n}\n\n.passage hr {\n border: none;\n border-top: 1px solid rgba(255, 255, 255, 0.12);\n margin: 1.2em 0;\n}\n\n.passage table {\n border-collapse: collapse;\n width: 100%;\n margin: 0.8em 0;\n}\n\n.passage th,\n.passage td {\n border: 1px solid rgba(255, 255, 255, 0.12);\n padding: 0.5em 0.75em;\n text-align: left;\n}\n\n.passage th {\n background: rgba(255, 255, 255, 0.06);\n font-weight: 600;\n}\n\n.passage tr:nth-child(even) {\n background: rgba(255, 255, 255, 0.02);\n}\n\n.passage del {\n opacity: 0.5;\n}\n\n.passage strong {\n color: #f0f0f0;\n}\n\n/* Macro: type (typewriter) */\n\n.macro-type-cursor::after {\n content: '\\2588';\n animation: type-blink 0.7s step-end infinite;\n color: #64b5f6;\n}\n\n.macro-type-done .macro-type-cursor::after {\n display: none;\n}\n\n@keyframes type-blink {\n 50% {\n opacity: 0;\n }\n}\n";
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";
20761
20847
  //#endregion
20762
20848
  //#region src/index.tsx
20763
20849
  function renderErrors(root, errors) {
@@ -10269,7 +10269,13 @@ function widgetDef({ name = "", parameters = "" }) {
10269
10269
  function parseWidgetDef(rawArgs) {
10270
10270
  return widgetDef(parseMacroArgs(rawArgs, WIDGET_PARAMETERS));
10271
10271
  }
10272
- var BLOCK_WIDGET = /\{widget\s+["']?(\w+)["']?[^}]*\}([\s\S]*?)\{\/widget\}/g;
10272
+ /**
10273
+ * A definition: its arguments (read like a {widget} macro's, see
10274
+ * parseWidgetDef) and its body. Macro names are not case-sensitive.
10275
+ */
10276
+ var WIDGET_DEFINITION = /\{widget\s+([^}]*)\}([\s\S]*?)\{\/widget\}/gi;
10277
+ /** `{@children}`, also with selectors such as `{.highlight @children}`. */
10278
+ var CHILDREN_PLACEHOLDER = /\{[^{}]*@children\s*\}/;
10273
10279
  /**
10274
10280
  * The names of the block widgets (those whose body renders `{@children}`)
10275
10281
  * that StoryInit and the passages tagged `widget` define. They must be
@@ -10280,7 +10286,12 @@ function blockWidgetNames(passages) {
10280
10286
  const names = [];
10281
10287
  for (const passage of passages) {
10282
10288
  if (passage.name !== "StoryInit" && !passage.tags?.includes("widget")) continue;
10283
- for (const match of passage.content.matchAll(BLOCK_WIDGET)) if (/\{@children\}/.test(match[2])) names.push(match[1]);
10289
+ for (const match of passage.content.matchAll(WIDGET_DEFINITION)) {
10290
+ if (!CHILDREN_PLACEHOLDER.test(match[2])) continue;
10291
+ try {
10292
+ names.push(parseWidgetDef(match[1]).name);
10293
+ } catch {}
10294
+ }
10284
10295
  }
10285
10296
  return names;
10286
10297
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.7",
3
+ "version": "0.59.8",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -5,6 +5,7 @@ import { NobrContext } from '../markup/render';
5
5
  import { StoryInterface } from './StoryInterface';
6
6
  import { TriggerDialogHost } from './TriggerDialogHost';
7
7
  import { RuntimeErrors } from './RuntimeErrors';
8
+ import { quickLoad, quickSave } from '../quick-actions';
8
9
 
9
10
  export function App() {
10
11
  const { storyData, currentPassage, nobr } = useStoryFields(
@@ -15,14 +16,13 @@ export function App() {
15
16
 
16
17
  useEffect(() => {
17
18
  const onKeyDown = (e: KeyboardEvent) => {
18
- const { quickSaveKey, quickLoadKey, save, load } =
19
- useStoryStore.getState();
19
+ const { quickSaveKey, quickLoadKey } = useStoryStore.getState();
20
20
  if (quickSaveKey !== null && e.key === quickSaveKey) {
21
21
  e.preventDefault();
22
- save();
22
+ quickSave();
23
23
  } else if (quickLoadKey !== null && e.key === quickLoadKey) {
24
24
  e.preventDefault();
25
- load();
25
+ quickLoad();
26
26
  }
27
27
  };
28
28
  document.addEventListener('keydown', onKeyDown);
@@ -2,6 +2,7 @@ import { useState } from 'preact/hooks';
2
2
  import { useStoryStore } from '../../store';
3
3
  import { useStoryFields } from '../../hooks/use-story-fields';
4
4
  import { settings } from '../../settings';
5
+ import { quickLoad, quickSave } from '../../quick-actions';
5
6
  import { defineMacro } from '../../define-macro';
6
7
  import { PassageDialog } from '../PassageDialog';
7
8
  import type { ActionType } from '../../action-registry';
@@ -110,8 +111,11 @@ defineMenubarAction({
110
111
  label: 'QuickSave',
111
112
  actionType: 'save',
112
113
  setup: () => {
113
- const { save, quickSaveKey } = useStoryFields('save', 'quickSaveKey');
114
- return { perform: save, title: hotkeyTitle('Quick Save', quickSaveKey) };
114
+ const { quickSaveKey } = useStoryFields('quickSaveKey');
115
+ return {
116
+ perform: quickSave,
117
+ title: hotkeyTitle('Quick Save', quickSaveKey),
118
+ };
115
119
  },
116
120
  });
117
121
 
@@ -122,14 +126,13 @@ defineMenubarAction({
122
126
  confirm: 'Load saved game? Current progress will be lost.',
123
127
  setup: () => {
124
128
  // knownSaves: re-render when the saves change, as hasSave() reads them
125
- const { load, hasSave, quickLoadKey } = useStoryFields(
126
- 'load',
129
+ const { hasSave, quickLoadKey } = useStoryFields(
127
130
  'hasSave',
128
131
  'quickLoadKey',
129
132
  'knownSaves',
130
133
  );
131
134
  return {
132
- perform: () => load(),
135
+ perform: quickLoad,
133
136
  disabled: !hasSave(),
134
137
  title: hotkeyTitle('Quick Load', quickLoadKey),
135
138
  };
@@ -80,6 +80,18 @@ function cleanupSnapshots(containerEl: Element | null): void {
80
80
  }
81
81
  }
82
82
 
83
+ /**
84
+ * `config` for players who prefer reduced motion: the CSS shortens the
85
+ * animations (see styles.css), so the waits that follow them shrink too,
86
+ * leaving no empty passage area before the next passage.
87
+ */
88
+ function reduceMotion(config: ResolvedTransition): ResolvedTransition {
89
+ if (!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
90
+ return config;
91
+ }
92
+ return { ...config, duration: Math.min(config.duration, 10), pause: 0 };
93
+ }
94
+
83
95
  defineMacro({
84
96
  name: 'passage',
85
97
  interpolate: true,
@@ -173,10 +185,8 @@ defineMacro({
173
185
  }
174
186
 
175
187
  // Resolve transition from tags → nextTransition → store default → built-in
176
- const config = resolveTransition(
177
- targetPassage.tags,
178
- next,
179
- transitionConfig,
188
+ const config = reduceMotion(
189
+ resolveTransition(targetPassage.tags, next, transitionConfig),
180
190
  );
181
191
 
182
192
  resolvedTypeRef.current = config.type;
@@ -56,6 +56,7 @@ export function SaveManagerContent() {
56
56
  } | null>(null);
57
57
  const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
58
58
  const [renamingId, setRenamingId] = useState<string | null>(null);
59
+ const renameCancelled = useRef(false);
59
60
  const [renameValue, setRenameValue] = useState('');
60
61
  const renameInputRef = useRef<HTMLInputElement>(null);
61
62
  const fileInputRef = useRef<HTMLInputElement>(null);
@@ -191,6 +192,7 @@ export function SaveManagerContent() {
191
192
  };
192
193
 
193
194
  const handleRenameStart = (save: SaveRecord) => {
195
+ renameCancelled.current = false;
194
196
  setRenamingId(save.meta.id);
195
197
  setRenameValue(save.meta.title);
196
198
  };
@@ -209,7 +211,18 @@ export function SaveManagerContent() {
209
211
 
210
212
  const handleRenameKeyDown = (e: KeyboardEvent) => {
211
213
  if (e.key === 'Enter') handleRenameConfirm();
212
- else if (e.key === 'Escape') setRenamingId(null);
214
+ else if (e.key === 'Escape') {
215
+ // Cancel the edit only: the dialog stays open, and the blur that
216
+ // follows removing the input must not confirm it
217
+ e.preventDefault();
218
+ e.stopPropagation();
219
+ renameCancelled.current = true;
220
+ setRenamingId(null);
221
+ }
222
+ };
223
+
224
+ const handleRenameBlur = () => {
225
+ if (!renameCancelled.current) handleRenameConfirm();
213
226
  };
214
227
 
215
228
  const handleExport = async (saveId: string) => {
@@ -350,7 +363,7 @@ export function SaveManagerContent() {
350
363
  )
351
364
  }
352
365
  onKeyDown={handleRenameKeyDown}
353
- onBlur={handleRenameConfirm}
366
+ onBlur={handleRenameBlur}
354
367
  />
355
368
  ) : (
356
369
  <div class="save-slot-title">{save.meta.title}</div>