code:
/***********************************************************************************************************************

	debugbar.js

	Copyright © 2013–2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/
/*
	global DebugView, Engine, L10n, Patterns, State, Util, session
*/

var DebugBar = (() => { // eslint-disable-line no-unused-vars, no-var
	'use strict';

	const _variableRe   = new RegExp(`^${Patterns.variable}$`);
	const _numericKeyRe = /^\d+$/;
	const _watchList    = [];
	let _$debugBar   = null;
	let _$watchBody  = null;
	let _$watchList  = null;
	let _$turnSelect = null;
	let _stowed      = true;

	/*******************************************************************************************************************
		Debug Bar Functions.
	*******************************************************************************************************************/
	function debugBarInit() {
		if (DEBUG) { console.log('[DebugBar/debugBarInit()]'); }

		/*
			Generate the debug bar elements and append them to the `<body>`.
		*/
		const barToggleLabel   = L10n.get('debugBarToggle');
		const watchAddLabel    = L10n.get('debugBarAddWatch');
		const watchAllLabel    = L10n.get('debugBarWatchAll');
		const watchNoneLabel   = L10n.get('debugBarWatchNone');
		const watchToggleLabel = L10n.get('debugBarWatchToggle');
		const viewsToggleLabel = L10n.get('debugBarViewsToggle');

		jQuery(document.createDocumentFragment())
			.append(
				/* eslint-disable max-len */
				  '
'
				+     '
'
				+         `<div>${L10n.get('debugBarNoWatches')}</div>>`
				+     '
'
				+     '
'
				+         `<button id="debug-bar-watch-toggle" tabindex="0" title="${watchToggleLabel}" aria-label="${watchToggleLabel}">${L10n.get('debugBarLabelWatch')}</button>`
				+         `<label id="debug-bar-watch-label" for="debug-bar-watch-input">${L10n.get('debugBarLabelAdd')}</label>`
				+         '<input id="debug-bar-watch-input" name="debug-bar-watch-input" type="text" list="debug-bar-watch-list" tabindex="0">'
				+         '<datalist id="debug-bar-watch-list" aria-hidden="true" hidden="hidden"></datalist>'
				+         `<button id="debug-bar-watch-add" tabindex="0" title="${watchAddLabel}" aria-label="${watchAddLabel}"></button>`
				+         `<button id="debug-bar-watch-all" tabindex="0" title="${watchAllLabel}" aria-label="${watchAllLabel}"></button>`
				+         `<button id="debug-bar-watch-none" tabindex="0" title="${watchNoneLabel}" aria-label="${watchNoneLabel}"></button>`
				+     '
'
				+     '
'
				+         `<button id="debug-bar-views-toggle" tabindex="0" title="${viewsToggleLabel}" aria-label="${viewsToggleLabel}">${L10n.get('debugBarLabelViews')}</button>`
				+         `<label id="debug-bar-turn-label" for="debug-bar-turn-select">${L10n.get('debugBarLabelTurn')}</label>`
				+         '<select id="debug-bar-turn-select" tabindex="0"></select>'
				+     '
'
				+     `<button id="debug-bar-toggle" tabindex="0" title="${barToggleLabel}" aria-label="${barToggleLabel}"></button>`
				+ '
'
				+ '

'
				/* eslint-enable max-len */
			)
			.appendTo('body');

		/*
			Cache various oft used elements.

			NOTE: We rewrap the elements themselves, rather than simply using
			the results of `find()`, so that we cache uncluttered jQuery-wrappers
			(i.e. `context` refers to the elements and there is no `prevObject`).
		*/
		_$debugBar   = jQuery('#debug-bar');
		_$watchBody  = jQuery(_$debugBar.find('#debug-bar-watch').get(0));
		_$watchList  = jQuery(_$debugBar.find('#debug-bar-watch-list').get(0));
		_$turnSelect = jQuery(_$debugBar.find('#debug-bar-turn-select').get(0));

		const $barToggle   = jQuery(_$debugBar.find('#debug-bar-toggle').get(0));
		const $watchToggle = jQuery(_$debugBar.find('#debug-bar-watch-toggle').get(0));
		const $watchInput  = jQuery(_$debugBar.find('#debug-bar-watch-input').get(0));
		const $watchAdd    = jQuery(_$debugBar.find('#debug-bar-watch-add').get(0));
		const $watchAll    = jQuery(_$debugBar.find('#debug-bar-watch-all').get(0));
		const $watchNone   = jQuery(_$debugBar.find('#debug-bar-watch-none').get(0));
		const $viewsToggle = jQuery(_$debugBar.find('#debug-bar-views-toggle').get(0));

		/*
			Set up the debug bar's local event handlers.
		*/
		$barToggle
			.ariaClick(debugBarToggle);
		$watchToggle
			.ariaClick(debugBarWatchToggle);
		$watchInput
			.on(':addwatch', function () {
				debugBarWatchAdd(this.value.trim());
				this.value = '';
			})
			.on('keypress', ev => {
				if (ev.which === 13) { // 13 is Return/Enter
					ev.preventDefault();
					$watchInput.trigger(':addwatch');
				}
			});
		$watchAdd
			.ariaClick(() => $watchInput.trigger(':addwatch'));
		$watchAll
			.ariaClick(debugBarWatchAddAll);
		$watchNone
			.ariaClick(debugBarWatchClear);
		_$turnSelect
			.on('change', function () {
				Engine.goTo(Number(this.value));
			});
		$viewsToggle
			.ariaClick(() => {
				DebugView.toggle();
				_updateSession();
			});

		/*
			Set up the debug bar's global event handlers.
		*/
		jQuery(document)
			// Set up a handler for the history select.
			.on(':historyupdate.debug-bar', _updateTurnSelect)
			// Set up a handler for the variables watch.
			.on(':passageend.debug-bar', () => {
				_updateWatchBody();
				_updateWatchList();
			})
			// Set up a handler for engine resets to clear the active debug session.
			.on(':enginerestart.debug-bar', _clearSession);

		/*
			Initially enable debug views if there's no active debug session.
		*/
		if (!_hasSession()) {
			DebugView.enable();
		}
	}

	function debugBarStart() {
		if (DEBUG) { console.log('[DebugBar/debugBarStart()]'); }

		// Attempt to restore an existing session.
		_restoreSession();

		// Update the UI.
		_updateBar();
		_updateTurnSelect();
		_updateWatchBody();
		_updateWatchList();
	}

	function debugBarIsStowed() {
		return _stowed;
	}

	function debugBarStow() {
		_debugBarStowNoUpdate();
		_stowed = true;
		_updateSession();
	}

	function debugBarUnstow() {
		_debugBarUnstowNoUpdate();
		_stowed = false;
		_updateSession();
	}

	function debugBarToggle() {
		if (_stowed) {
			debugBarUnstow();
		}
		else {
			debugBarStow();
		}
	}

	function debugBarWatchAdd(varName) {
		if (!_variableRe.test(varName)) {
			return;
		}

		_watchList.pushUnique(varName);
		_watchList.sort();
		_updateWatchBody();
		_updateWatchList();
		_updateSession();
	}

	function debugBarWatchAddAll() {
		Object.keys(State.variables).map(name => _watchList.pushUnique(`$${name}`));
		Object.keys(State.temporary).map(name => _watchList.pushUnique(`_${name}`));

		_watchList.sort();
		_updateWatchBody();
		_updateWatchList();
		_updateSession();
	}

	function debugBarWatchClear() {
		for (let i = _watchList.length - 1; i >= 0; --i) {
			_watchList.pop();
		}

		_updateWatchBody();
		_updateWatchList();
		_updateSession();
	}

	function debugBarWatchDelete(varName) {
		_watchList.delete(varName);
		_updateWatchBody();
		_updateWatchList();
		_updateSession();
	}

	function debugBarWatchDisable() {
		_debugBarWatchDisableNoUpdate();
		_updateSession();
	}

	function debugBarWatchEnable() {
		_debugBarWatchEnableNoUpdate();
		_updateSession();
	}

	function debugBarWatchIsEnabled() {
		return !_$watchBody.attr('hidden');
	}

	function debugBarWatchToggle() {
		if (_$watchBody.attr('hidden')) {
			debugBarWatchEnable();
		}
		else {
			debugBarWatchDisable();
		}
	}

	/*******************************************************************************************************************
		Utility Functions.
	*******************************************************************************************************************/
	function _debugBarStowNoUpdate() {
		_$debugBar.css('right', `-${_$debugBar.outerWidth()}px`);
	}

	function _debugBarUnstowNoUpdate() {
		_$debugBar.css('right', 0);
	}

	function _debugBarWatchDisableNoUpdate() {
		_$watchBody.attr({
			'aria-hidden' : true,
			hidden        : 'hidden'
		});
	}

	function _debugBarWatchEnableNoUpdate() {
		_$watchBody.removeAttr('aria-hidden hidden');
	}

	function _clearSession() {
		session.delete('debugState');
	}

	function _hasSession() {
		return session.has('debugState');
	}

	function _restoreSession() {
		if (!_hasSession()) {
			return false;
		}

		const debugState = session.get('debugState');

		_stowed = debugState.stowed;

		_watchList.push(...debugState.watchList);

		if (debugState.watchEnabled) {
			_debugBarWatchEnableNoUpdate();
		}
		else {
			_debugBarWatchDisableNoUpdate();
		}

		if (debugState.viewsEnabled) {
			DebugView.enable();
		}
		else {
			DebugView.disable();
		}

		return true;
	}

	function _updateSession() {
		session.set('debugState', {
			stowed       : _stowed,
			watchList    : _watchList,
			watchEnabled : debugBarWatchIsEnabled(),
			viewsEnabled : DebugView.isEnabled()
		});
	}

	function _updateBar() {
		if (_stowed) {
			debugBarStow();
		}
		else {
			debugBarUnstow();
		}
	}

	function _updateWatchBody() {
		if (_watchList.length === 0) {
			_$watchBody
				.empty()
				.append(`<div>${L10n.get('debugBarNoWatches')}</div>`);
			return;
		}

		const delLabel = L10n.get('debugBarDeleteWatch');
		const $table   = jQuery(document.createElement('table'));
		const $tbody   = jQuery(document.createElement('tbody'));

		for (let i = 0, len = _watchList.length; i < len; ++i) {
			const varName = _watchList[i];
			const varKey  = varName.slice(1);
			const store   = varName[0] === '$' ? State.variables : State.temporary;
			const $row    = jQuery(document.createElement('tr'));
			const $delBtn = jQuery(document.createElement('button'));
			const $code   = jQuery(document.createElement('code'));

			$delBtn
				.addClass('watch-delete')
				.attr('data-name', varName)
				.ariaClick({
					one   : true,
					label : delLabel
				}, () => debugBarWatchDelete(varName));
			$code
				.text(_toWatchString(store[varKey]));

			jQuery(document.createElement('td'))
				.append($delBtn)
				.appendTo($row);
			jQuery(document.createElement('td'))
				.text(varName)
				.appendTo($row);
			jQuery(document.createElement('td'))
				.append($code)
				.appendTo($row);
			$row
				.appendTo($tbody);
		}

		$table
			.append($tbody);
		_$watchBody
			.empty()
			.append($table);
	}

	function _updateWatchList() {
		const svn = Object.keys(State.variables);
		const tvn = Object.keys(State.temporary);

		if (svn.length === 0 && tvn.length === 0) {
			_$watchList.empty();
			return;
		}

		const names   = [...svn.map(name => `$${name}`), ...tvn.map(name => `_${name}`)].sort();
		const options = document.createDocumentFragment();

		names.delete(_watchList);

		for (let i = 0, len = names.length; i < len; ++i) {
			jQuery(document.createElement('option'))
				.val(names[i])
				.appendTo(options);
		}

		_$watchList
			.empty()
			.append(options);
	}

	function _updateTurnSelect() {
		const histLen = State.size;
		const expLen  = State.expired.length;
		const options = document.createDocumentFragment();

		for (let i = 0; i < histLen; ++i) {
			jQuery(document.createElement('option'))
				.val(i)
				.text(`${expLen + i + 1}. ${Util.escape(State.history[i].title)}`)
				.appendTo(options);
		}

		_$turnSelect
			.empty()
			.ariaDisabled(histLen < 2)
			.append(options)
			.val(State.activeIndex);
	}

	function _toWatchString(value) {
		/*
			Handle the `null` primitive.
		*/
		if (value === null) {
			return 'null';
		}

		/*
			Handle the rest of the primitives and functions.
		*/
		switch (typeof value) {
		case 'number':
			if (Number.isNaN(value)) {
				return 'NaN';
			}
			else if (!Number.isFinite(value)) {
				return 'Infinity';
			}
			/* falls through */
		case 'boolean':
		case 'symbol':
		case 'undefined':
			return String(value);

		case 'string':
			return JSON.stringify(value);

		case 'function':
			return 'Function';
		}

		const objType = Util.toStringTag(value);

		// /*
		// 	Handle instances of the primitive exemplar objects (`Boolean`, `Number`, `String`).
		// */
		// if (objType === 'Boolean') {
		// 	return `Boolean\u202F{${String(value)}}`;
		// }
		// if (objType === 'Number') {
		// 	return `Number\u202F{${String(value)}}`;
		// }
		// if (objType === 'String') {
		// 	return `String\u202F{"${String(value)}"}`;
		// }

		/*
			Handle `Date` objects.
		*/
		if (objType === 'Date') {
			// return `Date\u202F${value.toISOString()}`;
			return `Date\u202F{${value.toLocaleString()}}`;
		}

		/*
			Handle `RegExp` objects.
		*/
		if (objType === 'RegExp') {
			return `RegExp\u202F${value.toString()}`;
		}

		const result = [];

		/*
			Handle `Array` & `Set` objects.
		*/
		if (value instanceof Array || value instanceof Set) {
			const list = value instanceof Array ? value : Array.from(value);

			// own numeric properties
			// NOTE: Do not use `<Array>.forEach()` here as it skips undefined members.
			for (let i = 0, len = list.length; i < len; ++i) {
				result.push(list.hasOwnProperty(i) ? _toWatchString(list[i]) : '<empty>');
			}

			// own enumerable non-numeric expando properties
			Object.keys(list)
				.filter(key => !_numericKeyRe.test(key))
				.forEach(key => result.push(`${_toWatchString(key)}: ${_toWatchString(list[key])}`));

			return `${objType}(${list.length})\u202F[${result.join(', ')}]`;
		}

		/*
			Handle `Map` objects.
		*/
		if (value instanceof Map) {
			value.forEach((val, key) => result.push(`${_toWatchString(key)} \u2192 ${_toWatchString(val)}`));

			return `${objType}(${value.size})\u202F{${result.join(', ')}}`;
		}

		/*
			General object handling.
		*/
		// own enumerable properties
		Object.keys(value)
			.forEach(key => result.push(`${_toWatchString(key)}: ${_toWatchString(value[key])}`));

		return `${objType}\u202F{${result.join(', ')}}`;
	}

	/*******************************************************************************************************************
		Module Exports.
	*******************************************************************************************************************/
	return Object.freeze(Object.defineProperties({}, {
		/*
			Debug Bar Functions.
		*/
		init     : { value : debugBarInit },
		isStowed : { value : debugBarIsStowed },
		start    : { value : debugBarStart },
		stow     : { value : debugBarStow },
		toggle   : { value : debugBarToggle },
		unstow   : { value : debugBarUnstow },

		/*
			Watch Functions.
		*/
		watch : {
			value : Object.freeze(Object.defineProperties({}, {
				add       : { value : debugBarWatchAdd },
				all       : { value : debugBarWatchAddAll },
				clear     : { value : debugBarWatchClear },
				delete    : { value : debugBarWatchDelete },
				disable   : { value : debugBarWatchDisable },
				enable    : { value : debugBarWatchEnable },
				isEnabled : { value : debugBarWatchIsEnabled },
				toggle    : { value : debugBarWatchToggle }
			}))
		}
	}));
})();


code:
/***********************************************************************************************************************

	dialog.js

	Copyright © 2013–2023 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/
/* global Has, L10n, Story, getTypeOf */

var Dialog = (() => { // eslint-disable-line no-unused-vars, no-var
	'use strict';

	// Default top position.
	const DEFAULT_TOP = 50; // in pixels w/o unit

	// jQuery-wrapped dialog elements.
	let $overlay = null;
	let $dialog  = null;
	let $title   = null;
	let $body    = null;

	// Last active/focused non-dialog element.
	let lastActive = null;

	// Mutation resize handler.
	let observer = null;

	// Active close callback, if any.
	let onCloseFn = null;

	// Width of the browser's scrollbars.
	let scrollbarWidth = 0;

	/*******************************************************************************
		Initialization Functions.
	*******************************************************************************/

	function init() {
		if (DEBUG) { console.log('[Dialog/init()]'); }

		if (document.getElementById('ui-dialog')) {
			return;
		}

		// Calculate and record the width of scrollbars.
		scrollbarWidth = (() => {
			let calcWidth;

			try {
				const inner = document.createElement('p');
				inner.style.width  = '100%';
				inner.style.height = '200px';

				const outer = document.createElement('div');
				outer.style.position   = 'absolute';
				outer.style.left       = '0';
				outer.style.top        = '0';
				outer.style.width      = '100px';
				outer.style.height     = '100px';
				outer.style.visibility = 'hidden';
				outer.style.overflow   = 'hidden';

				outer.appendChild(inner);
				document.body.appendChild(outer);

				const w1 = inner.offsetWidth;
				// The `overflow: scroll` style property value does not work consistently
				// with scrollbars which are styled with `::-webkit-scrollbar`, so we use
				// `overflow: auto` with dimensions guaranteed to force a scrollbar.
				outer.style.overflow = 'auto';
				let w2 = inner.offsetWidth;

				if (w1 === w2) {
					w2 = outer.clientWidth;
				}

				document.body.removeChild(outer);

				calcWidth = w1 - w2;
			}
			catch (ex) { /* no-op */ }

			return calcWidth || 17; // 17px is a reasonable failover
		})();

		// Generate the dialog elements.
		const $elems = jQuery(document.createDocumentFragment())
			.append(
				/* eslint-disable max-len */
				  '

'
				+ '
'
				+     '
'
				+         '

'
				+         `<button id="ui-dialog-close" class="ui-close" tabindex="0" aria-label="${L10n.get('textClose')}">\ue804</button>`
				+     '
'
				+     '

'
				+ '
'
				/* eslint-enable max-len */
			);

		// Cache the dialog elements, since they're going to be used often.
		//
		// NOTE: We rewrap the elements themselves, rather than simply using
		// the results of `find()`, so that we cache uncluttered jQuery-wrappers
		// (i.e. `context` refers to the elements and there is no `prevObject`).
		$overlay = jQuery($elems.find('#ui-overlay').get(0));
		$dialog  = jQuery($elems.find('#ui-dialog').get(0));
		$title   = jQuery($elems.find('#ui-dialog-title').get(0));
		$body    = jQuery($elems.find('#ui-dialog-body').get(0));

		// Insert the dialog elements into the page before the main script.
		$elems.insertBefore('body>script#script-sugarcube');
	}

	/*******************************************************************************
		Utility Functions.
	*******************************************************************************/

	/*
		Calculate the inset values, in pixels, required to fit the dialog within
		the current viewport based on the size of its contents and the viewport's
		dimentions.
	*/
	function calcInset(top) {
		const $window = jQuery(window);
		const inset   = { left : '', right : '', top : '', bottom : '' };
		const minPos  = 10;

		// Unset the dialog's inset values, so the browser can resize it based on
		// its content.
		$dialog.css(inset);

		// Calculate the dialog's new inset values based on its current dimensions.
		//
		// NOTE: Subtract `1` from both space values to address a Firefox issue.
		// QUESTION: Is this still necessary?
		let horzSpace = $window.width() - $dialog.outerWidth(true) - 1;
		let vertSpace = $window.height() - $dialog.outerHeight(true) - 1;

		if (horzSpace <= minPos * 2 + scrollbarWidth) {
			vertSpace -= scrollbarWidth;
		}

		if (vertSpace <= minPos * 2 + scrollbarWidth) {
			horzSpace -= scrollbarWidth;
		}

		/* eslint-disable prefer-template */
		// Calculate the horizontal inset values in pixels.
		if (horzSpace <= minPos * 2) {
			inset.left = inset.right = minPos + 'px';
		}
		else {
			inset.left = inset.right = (horzSpace / 2 >> 0) + 'px';
		}

		// Calculate the vertical inset values in pixels.
		if (vertSpace <= minPos * 2) {
			inset.top = inset.bottom = minPos + 'px';
		}
		else {
			const vertPos = vertSpace / 2 >> 0;

			if (vertPos > top) {
				inset.top = top + 'px';
			}
			else {
				inset.top = inset.bottom = vertPos + 'px';
			}
		}
		/* eslint-enable prefer-template */

		return inset;
	}

	/*
		Resize handler.
	*/
	function onResize(top) {
		if ($dialog.css('display') === 'block') {
			$dialog.css(calcInset(top != null ? top : DEFAULT_TOP)); // lazy equality for null
		}
	}

	/*******************************************************************************
		API Functions.
	*******************************************************************************/

	/*
		Appends the specified content sources to the dialog's body container.
		Returns `Dialog` for further chaining.
	*/
	function append(...args) {
		$body.append(...args);
		return Dialog;
	}

	/*
		Closes and resets the dialog.
		Returns `Dialog` for further chaining.
	*/
	function close(ev) {
		// Trigger a `:dialogclosing` event on the dialog body.
		$body.trigger(':dialogclosing');

		// Largely reverse the actions taken in `dialogOpen()`.
		jQuery(document).off('.dialog-close');

		if (observer) {
			observer.disconnect();
			observer = null;
		}
		else {
			$body.off('.dialog-resize');
		}

		jQuery(window)
			.off('.dialog-resize');
		$dialog
			.removeClass('open')
			.css({ left : '', right : '', top : '', bottom : '' });

		jQuery('#ui-bar,#story')
			.find('[tabindex=-2]')
			.removeAttr('aria-hidden')
			.attr('tabindex', 0);
		jQuery('body>[tabindex=-3]')
			.removeAttr('aria-hidden')
			.removeAttr('tabindex');

		$overlay
			.removeClass('open');
		jQuery(document.documentElement)
			.removeAttr('data-dialog');

		// Clear the dialog's content.
		$title
			.empty();
		$body
			.empty()
			.removeClass();

		// Attempt to restore focus to whichever element had it prior to opening the dialog.
		if (lastActive) {
			lastActive.focus();
			lastActive = null;
		}

		// Call the given close callback, if any.
		if (onCloseFn) {
			// NOTE: There's no catch clause here because this try/finally exists
			// solely to ensure that the close callback is properly reset in the
			// event that an uncaught exception is thrown during the callback call.
			try {
				onCloseFn(ev);
			}
			finally {
				onCloseFn = null;
			}
		}

		// Trigger a `:dialogclosed` event on the dialog body.
		/* legacy */
		$body.trigger(':dialogclose');
		/* /legacy */
		$body.trigger(':dialogclosed');

		return Dialog;
	}

	/*
		Prepares the dialog for use.
		Returns `Dialog` for further chaining.
	*/
	function create(title, classNames) {
		$title
			.empty()
			.append((title != null ? String(title) : '') || '\u00A0'); // lazy equality for null

		$body
			.empty()
			.removeClass();

		if (classNames != null) { // lazy equality for null
			$body.addClass(classNames);
		}

		return Dialog;
	}

	/*
		Empties the dialog's body container.
		Returns `Dialog` for further chaining.
	*/
	function empty() {
		$body.empty();
		return Dialog;
	}

	/*
		Returns the dialog's body container.
	*/
	function getBody() {
		return $body.get(0);
	}

	/*
		Returns whether the dialog is open.
		The test may be narrowed by specifing class names.
	*/
	function isOpen(classNames) {
		return $dialog.hasClass('open')
			&& (classNames ? classNames.splitOrEmpty(/\s+/).every(cn => $body.hasClass(cn)) : true);
	}

	/*
		Opens the dialog.
		Returns `Dialog` for further chaining.
	*/
	function open(options, onClose) {
		// Grab the options we care about.
		const { top } = Object.assign({ top : DEFAULT_TOP }, options);

		// Record the given close callback.
		if (onClose != null) { // lazy equality for null
			const closeType = getTypeOf(onClose);

			if (closeType !== 'function') {
				throw new TypeError(`Dialog.open onClose parameter must be a function (received: ${closeType})`);
			}

			onCloseFn = onClose;
		}
		else {
			onCloseFn = null;
		}

		// Trigger a `:dialogopening` event on the dialog body.
		$body.trigger(':dialogopening');

		// Record the last active/focused non-dialog element.
		if (!isOpen()) {
			lastActive = document.activeElement || null;
		}

		// Add the `data-dialog` attribute to  (mostly used to style ).
		jQuery(document.documentElement).attr('data-dialog', 'open');

		// Display the overlay.
		$overlay.addClass('open');

		// Add `aria-hidden=true` to all direct non-dialog-children of  to
		// hide the underlying page from screen readers while the dialog is open.
		jQuery('body>:not(script,#store-area,tw-storydata,#ui-bar,#ui-overlay,#ui-dialog)')
			.attr('tabindex', -3)
			.attr('aria-hidden', true);
		jQuery('#ui-bar,#story')
			.find('[tabindex]:not([tabindex^=-])')
			.attr('tabindex', -2)
			.attr('aria-hidden', true);

		// Create our throttled resize handler.
		const resizeHandler = jQuery.throttle(40, () => onResize(top));

		// Add the imagesLoaded handlers to images.
		$body.imagesLoaded().always(resizeHandler);

		// Display the dialog.
		$dialog
			.css(calcInset(top))
			.addClass('open')
			.focus();

		// Attach the window `resize` event resize handler.
		jQuery(window)
			.off('.dialog-resize')
			.on('resize.dialog-resize', resizeHandler);

		// Add the dialog mutation resize handler.
		if (Has.mutationObserver) {
			observer = new MutationObserver(mutations => {
				for (let i = 0; i < mutations.length; ++i) {
					if (mutations[i].type === 'childList') {
						$body.imagesLoaded().always(resizeHandler);
						resizeHandler();
						break;
					}
				}
			});
			observer.observe(getBody(), {
				childList : true,
				subtree   : true
			});
		}
		else {
			$body
				.off('.dialog-resize')
				.on(
					'DOMNodeInserted.dialog-resize DOMNodeRemoved.dialog-resize',
					() => {
						$body.imagesLoaded().always(resizeHandler);
						resizeHandler();
					}
				);
		}

		// Set up the delegated close handler.
		jQuery(document)
			.off('.dialog-close')
			.one('click.dialog-close', '.ui-close', ev => {
				// NOTE: Do not allow this event handler to return the `Dialog` static object,
				// as doing so causes Edge (ca. 18) to throw a "Number expected" exception due
				// to `Dialog` not having a prototype.
				close(ev);
				/* implicit `return undefined;` */
			})
			.one('keypress.dialog-close', '.ui-close', function (ev) {
				// 13 is Enter/Return, 32 is Space.
				if (ev.which === 13 || ev.which === 32) {
					jQuery(this).trigger('click');
				}
			});

		// Trigger a `:dialogopened` event on the dialog body.
		/* legacy */
		$body.trigger(':dialogopen');
		/* /legacy */
		$body.trigger(':dialogopened');

		return Dialog;
	}

	/*
		Resize the dialog.
	*/
	function resize(options) {
		return onResize(typeof options === 'object' ? options.top : undefined);
	}

	/*
		Renders and appends the specified content sources to the dialog's body container.
		Returns `Dialog` for further chaining.
	*/
	function wiki(...args) {
		$body.wiki(...args);
		return Dialog;
	}

	/*
		Renders and appends the specified passage to the dialog's body container.
		Returns `Dialog` for further chaining.
	*/
	function wikiPassage(name) {
		return wiki(Story.get(name).processText());
	}

	/*******************************************************************************
		Deprecated Functions.
	*******************************************************************************/

	/*
		[DEPRECATED] Prepares the dialog for use.
		Returns the dialog's body container.
	*/
	function setup(title, classNames) {
		// console.warn('[DEPRECATED] Dialog.setup() is deprecated.');

		create(title, classNames);
		return getBody();
	}

	/*******************************************************************************
		Object Exports.
	*******************************************************************************/

	return Object.preventExtensions(Object.create(null, {
		append      : { value : append },
		body        : { value : getBody },
		close       : { value : close },
		create      : { value : create },
		empty       : { value : empty },
		init        : { value : init },
		isOpen      : { value : isOpen },
		open        : { value : open },
		resize      : { value : resize },
		wiki        : { value : wiki },
		wikiPassage : { value : wikiPassage },

		// Deprecated Functions.
		setup : { value : setup }
	}));
})();


code:
/***********************************************************************************************************************

	engine.js

	Copyright © 2013–2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/
/*
	global Alert, Config, DebugView, Dialog, Has, LoadScreen, Save, State, Story, StyleWrapper, UI, UIBar, Util,
	       Wikifier, postdisplay, postrender, predisplay, prehistory, prerender, setDisplayTitle
*/

var Engine = (() => { // eslint-disable-line no-unused-vars, no-var
	'use strict';

	// Engine state types object (pseudo-enumeration).
	const States = Util.toEnum({
		Idle      : 'idle',
		Playing   : 'playing',
		Rendering : 'rendering'
	});

	// Minimum delay for DOM actions (in milliseconds).
	const minDomActionDelay = 40;

	// Cache of the debug view(s) for initialization special passage(s).
	const _initDebugViews = [];

	// Current state of the engine (default: `Engine.States.Idle`).
	let _state = States.Idle;

	// Last time `enginePlay()` was called (in milliseconds).
	let _lastPlay = null;

	// Cache of the outline patching


code:
/* eslint no-undef: "off", no-param-reassign: "off", no-alert: "off", no-fallthrough: "off", no-dupe-args: "warn", no-irregular-whitespace: "warn", max-len: "off", key-spacing: ["warn", {beforeColon: false, afterColon: true}], comma-dangle: ["warn", "always-multiline"], quotes: ["warn", "double"], indent: ["warn", "tab", {SwitchCase: 1}], id-length: "off", prefer-template: "off", brace-style: ["warn", "1tbs"] */
/**
 * hotkeys support for links and buttons
 */

const Links = (() => {
	"use strict";

	let currentLinks = [];
	let numberPrepend = "(";
	let numberAppend = ") ";
	let enabled = true;
	let disableNumbers = false;
	let disableRNGReload = false;
	let keyNumberMatcher;
	let maxKeyDescLength;
	let throttle = false;
	let shiftDown = false;
	let skipElements = ".no-numberify, .no-numberify *"; // here, we match class "no-numberify", and then also all it's children
	let includeElements = ""; // here, we can set up a matcher for exceptions that shouldn't be skipped

	function keyNumberMatcherUpdate() {
		keyNumberMatcher = new RegExp(RegExp.escape(numberPrepend) + "((Ctrl|Alt|Shift) \\+ )?\\d" + RegExp.escape(numberAppend));
		// limit the search to as little characters from the start of the line as possible to eliminate or at least reduce false-positives with custom append/prepend values
		maxKeyDescLength = numberAppend.length + numberPrepend.length + 9;
	}
	keyNumberMatcherUpdate();

	function getPrettyKeyNumber(counter) {
		let str = "";

		switch (Math.floor((counter - 1) / 10)) { // 10 should be counted as 0
			case 3: str = "Alt + "; break;
			case 2: str = "Ctrl + "; break;
			case 1: str = "Shift + "; break;
		}
		str += (counter % 10).toString();

		return str;
	}

	function generateLinkNumbers(content, visibility) {
		if (!enabled || disableNumbers || V.options && !V.options.numberify_enabled) return;

		// don't run this too often. ward off the worst outcomes of bad programming that would trigger massive <<replace>> spam
		const stamp = performance.now();
		if (throttle + 100 > stamp) {
			throttle = stamp;
			generateDebounce();
			return;
		}
		throttle = stamp;

		// find all visible .link-internal elements, then remove from them all skipElements unless they are also in includeElements
		if (visibility) {
			// using :hidden pseudo-class is preferred for telling actual visibility of the link, but it's not available at the passagerender time
			currentLinks = $(content).find(".link-internal").not(":hidden");
		} else {
			currentLinks = $(content).find(".link-internal").filter((i, link) => getComputedStyle(link).display !== "none");
		}
		if (skipElements && includeElements) {
			const goodies = $(content).find(includeElements);
			const baddies = $(content).find(skipElements).not(goodies);
			currentLinks = currentLinks.not(baddies);
		} else if (skipElements) {
			const baddies = $(content).find(skipElements);
			currentLinks = currentLinks.not(baddies);
		}

		for (let i = 0; i < Math.min(currentLinks.length, 40); i++) {
			const el = currentLinks[i];
			const keyNumber = numberPrepend + getPrettyKeyNumber(i + 1) + numberAppend;
			if (keyNumberMatcher.test(el.innerHTML.slice(0, maxKeyDescLength))) {
				// replace previously assigned number
				el.innerHTML = el.innerHTML.replace(keyNumberMatcher, keyNumber);
			} else {
				el.prepend(keyNumber);
			}
		}
		if (enabled === "debug") console.log(`Links: generated ${currentLinks.length} links, took ${performance.now() - stamp}ms"`);
	}

	// this is a mostly user-triggered function that is almost guaranteed to have the passage already rendered
	function generate() {
		return generateLinkNumbers(document.getElementsByClassName("passage")[0] || document, true);
	}

	// and this is our bouncer that we employ to prevent unwanted spam
	const generateDebounce = $.debounce(200, generate);

	function linkFollow(index) {
		if (disableNumbers) return;
		if ($(currentLinks).length >= index) $(currentLinks[index - 1].click());
	}

	function inputFocused() {
		if (["INPUT", "TEXTAREA"].includes(document.activeElement.tagName) && !["radio", "button", "checkbox", "submit", "reset", "image"].includes(document.activeElement.type)) return true;
		return false;
	}

	function init() {
		// collect all links and assign their numbers
		$(document).on(":passagerender", ev => {
			currentLinks = [];
			throttle = 0;
			generateLinkNumbers(ev.content);
		});

		// prevent numpad keys from triggering browser's default shortcuts
		$(document).on("keydown", ev => {
			if (ev.code.startsWith("Shift")) return shiftDown = true;
			if (inputFocused()) return;
			if (ev.code.startsWith("Numpad")) ev.preventDefault();
		});

		// assign shortcuts
		$(document).on("keyup", ev => {
			if (ev.code.startsWith("Shift")) return shiftDown = false;
			if (!enabled || V.tempDisable || V.options && !V.options.numberify_enabled || inputFocused()) return;
			if (Dialog.isOpen()) return ev.code === "Escape" ? Dialog.close() : false;

			let offset = 0;
			if (ev.shiftKey) offset = 10;
			else if (ev.code.startsWith("Numpad") && shiftDown) offset = 10; // windows must die
			else if (ev.ctrlKey) offset = 20;
			else if (ev.altKey) offset = 30;

			switch (ev.code) {
				case "Digit1": case "Numpad1": case "KeyN":
					linkFollow(offset + 1);
					break;
				case "Digit2": case "Numpad2":
					linkFollow(offset + 2);
					break;
				case "Digit3": case "Numpad3":
					linkFollow(offset + 3);
					break;
				case "Digit4": case "Numpad4":
					linkFollow(offset + 4);
					break;
				case "Digit5": case "Numpad5":
					linkFollow(offset + 5);
					break;
				case "Digit6": case "Numpad6":
					linkFollow(offset + 6);
					break;
				case "Digit7": case "Numpad7":
					linkFollow(offset + 7);
					break;
				case "Digit8": case "Numpad8":
					linkFollow(offset + 8);
					break;
				case "Digit9": case "Numpad9":
					linkFollow(offset + 9);
					break;
				case "Digit0": case "Numpad0":
					linkFollow(offset + 10);
					break;
				case "NumpadDivide":
					// go back in history, twice if shift is pressed
					if (ev.shiftKey) Engine.go(-2);
					else Engine.backward();
					break;
				case "NumpadMultiply":
					// reload current page with different rng
					if (disableRNGReload) break; // let game devs disable potentially cheaty option
					State.restore(true);
					// State.unmarshalForSave(State.marshalForSave()); // save and immediately reload current state
					if (State.prng.isEnabled()) { // hack for predictable rng
						State.random(); // update rng pool
						const frame = State.history[State.activeIndex]; // active history frame
						frame.pull = State.prng.pull; // update pull
					}
					Engine.show();
					break;
				case "NumpadSubtract":
					// go forward in history
					if (ev.shiftKey) Engine.go(2);
					else Engine.forward();
					break;
			}
		});
	}

	return Object.freeze(Object.defineProperties({}, {
		init: { value: init },
		generate: { value: generate },
		generateLinkNumbers: { value: generateLinkNumbers },
		pushTheButton:       { value: linkFollow },
		numberPrepend:       { get() { return numberPrepend;    }, set(val) { numberPrepend = val; keyNumberMatcherUpdate(); } },
		numberAppend:        { get() { return numberAppend;     }, set(val) { numberAppend = val; keyNumberMatcherUpdate(); } },
		skipElements:        { get() { return skipElements;     }, set(val) { skipElements = val; } },
		includeElements:     { get() { return includeElements;  }, set(val) { includeElements = val; } },
		enabled:             { get() { return enabled;          }, set(val) { enabled = val; } },
		disableRNGReload:    { get() { return disableRNGReload; }, set(val) { disableRNGReload = val; } },
		disableNumbers:      { get() { return disableNumbers;   }, set(val) { disableNumbers = val; } },
		throttle:            { get() { return throttle;         }, set(val) { throttle = val; } },
		currentLinks:        { get() { return currentLinks;     } },
	}));
})();
window.Links = Links;


code:
/* eslint no-undef: "off", no-param-reassign: "off", no-alert: "off", no-fallthrough: "off", no-dupe-args: "warn", no-irregular-whitespace: "warn", max-len: "off", key-spacing: ["warn", {beforeColon: false, afterColon: true}], comma-dangle: ["warn", "always-multiline"], quotes: ["warn", "double"], indent: ["warn", "tab", {SwitchCase: 1}], id-length: "off", brace-style: ["warn", "1tbs"] */

/*
 * "simple" indexedDB backend for storing save data, working similarly to existing webStorage system
 * indexedDB works faster, has virtually unlimited storage, but does not work properly in private mode. then again, localStorage doesn't persist in private mode either
 * indexedDB operates asynchronously, by making requests that may be fulfilled or rejected later, without blocking the rest of the code, but also without a guarantee that requested values will be available when that rest of the code runs. this requires some working around.
 * unlike old synchronous operations, most functions do not return the value immediately, but a promise to return it when it's completed. these promises can then be used to retrieve that data by calling Promise.then() callback function
 * for example, `idb.getItem(0).then((value) => console.log(value))` will first attempt to retrieve save data from slot 0, and then when that is done - the then() function triggers, in this case printing retrieved value to the console
 *
 * this implementation doesn't rely on caches, doesn't compress save data in any way, and separates save details store from save data store to speed up building the save list and allow extra features like timestamp highlighting at minimal processing cost
 * as a consequence, it requires more disk space, and a completely separate namespace that might need extra setup for games that override the default save list appearance
 * generally though, just adding a "saveList" id or class to the div element where the saves should appear and replacing the function/macro that populates that div with "if (idb.active) idb.saveList(); else old-custom-way-of-building-save-menu" should be enough to make it work.
 */

/* global State, Story, Save, clone */

const idb = (() => {
	"use strict";

	// return early if indexedDB is unavailable
	if (window.indexedDB == null) return Object.freeze({
		lock: true,
		/* eslint-disable brace-style */
		init() { return false; },
		get active() { return false; },
		set active(_) { return false; },
		get footerHTML() { return false; },
		set footerHTML(_) { return false; },
		/* eslint-enable brace-style */
	});

	let _lock = true; // don't allow multiple operations at the same time, majority of sugarcube is not async
	let _active = true; // whether to use indexedDB or the old localStorage
	let _dbName = "idb"; // database name
	let _migrationNeeded = false; // flag to migrate old saves
	let _settings = {}; // persistent db settings stored in localStorage
	updateSettings();
	let _saveDetails = []; // cache so we don't have to query all items from details store on every page change

	function log(description, data, useClone) {
		console.log(description, useClone ? clone(data) : data);
		if (window.Errors) Errors.report(description, data, useClone);
		else alert(`${description}\n${JSON.stringify(data)}`);
	}

	// bring the database up to date
	const _version = 1;
	function dbUpgrade(ev) {
		const db = ev.target.result;
		const ver = ev.oldVersion;
		console.log("updating idb", ver);
		switch (ver) {
			case 0:
				// first time opening, create stores for fat saves and slim details
				db.createObjectStore("saves", { keyPath: "slot" });
				db.createObjectStore("details", { keyPath: "slot" });
				_migrationNeeded = true; // flag localStorage saves for migration
				break;
			case 1:
				// reserved for upgrading from ver 1 in the future
		}
	}

	// open the database
	function openDB(name = _dbName, version = _version) {
		return new Promise((resolve, reject) => {
			_dbName = name;
			const openRequest = indexedDB.open(idb.dbName, version);
			openRequest.onupgradeneeded = dbUpgrade;
			openRequest.onerror = ev => {
				const err = ev.target.error;
				log("error opening idb", err);
				reject(err);
			};
			// indexedDB is opened, mark the rest of the system as active
			openRequest.onsuccess = ev => {
				_lock = false;
				if (navigator.storage && typeof navigator.storage.persist === "function") navigator.storage.persist();
				const db = ev.target;
				db.onclose = ev => {
					_active = false;
					log("ERROR: idb connection closed unexpectedly", ev);
				};
				db.onerror = ev => {
					_active = false;
					log("Database error", ev.target.errorCode);
				};
				if (_migrationNeeded) {
					importFromLocalStorage();
					_migrationNeeded = false;
				}
				resolve(db.result);
			};
			openRequest.onblocked = () => {
				console.log("something went wrong", openRequest.error);
				// reject(openRequest.error);
			};
		});
	}

	/**
	 * synchronize internal settings with persistent storage
	 * allowing them to survive page reload
	 *
	 * @param {string} setting accessor to modify
	 * @param {boolean} value to set
	 */
	function updateSettings(setting, value) {
		const storageName = "idb-settings";
		_settings = JSON.parse(localStorage.getItem(storageName)) || {
			warnSave: V.confirmSave || false,
			warnLoad: V.confirmLoad || false,
			warnDelete: V.confirmDelete || true,
			active: !window.FCHostPersistent,
			useDelta: true,
		};
		_active = _settings.active; // one-way sync, only change default when triggered by user, not by fail-safes
		if (!setting) return;
		if (!['warnSave', 'warnLoad', 'warnDelete', 'active', 'useDelta'].includes(setting)) return console.warn(`idbupdatesettings: invalid argument: ${setting}`);
		if (value == null) return _settings[setting];
		_settings[setting] = value;
		_active = _settings.active; // do it again
		localStorage.setItem(storageName, JSON.stringify(_settings));
	}
	updateSettings();

	const baddies = [];
	/**
	 * scan and stringify functions that wormed their way into story vars
	 * and other objects with custom toJSON revivals
	 *
	 * @param {object} target to scan
	 * @param {object} path to report
	 * @param {boolean} verbose flag to report objects too complex for idb
	 */
	function funNuke(target, path = "", verbose = true) {
		if (!target) return console.log("no target specified");
		for (const key in target) {
			const value = target[key];
			const newPath = `${path}['${key}']`;
			if (value == null) continue;
			else if (typeof value === "function" || value.toJSON) {
				// we've got a baddie, round him up!
				if (verbose && V.idbTest) {
					console.log(`Warn: ${newPath} of type ${typeof value} shouldn't be in STORY variables!!!`);
				}
				target[key] = JSON.stringify(value);
				baddies.push(newPath);
			} else if (typeof value === "object") funNuke(value, newPath, verbose);
		}
	}

	/**
	 * restore nuked functions and other nasty stuff
	 *
	 * @param {object} target store to alter
	 * @param {array} paths to restore
	 */
	function ekuNnuf(target = V, paths) {
		/**
		 * sub-function to revive specified path
		 *
		 * @param {object} target
		 * @param {string} path string in a format "['path']['to']['object']"
		 * @returns true on success
		 */
		function revive(target, path) {
			if (typeof path !== "string" || path === "") return console.log("Warn: invalid path", clone(path));
			const accessors = path.slice(2,-2).split("']['");
			let ref = target;
			for (let i = 0, destination = accessors.length - 1; i <= destination; i++) {
				if (i === destination) ref[accessors[i]] = JSON.parse(ref[accessors[i]]);
				else ref = ref[accessors[i]];
			}
			return true;
		}

		let path = "";
		while (path = paths.shift()) {
			try {
				revive(target, path);
			} catch (ex) {
				console.log("WARN: couldn't restore story var function", path);
			}
		}
	}

	/**
	 * copy saves from localStorage into indexedDB, without regard for what's already in there
	 *
	 * @returns {boolean} success of the operation
	 */
	async function importFromLocalStorage() {
		function processSave(fullSaveObj) {
			const save = fullSaveObj.state;
			if (save.jdelta) delete save.jdelta; // jdelta wasn't a great idea
			if (save.delta) save.history = State.deltaDecode(save.delta);
			delete save.delta;
			if (window.DoLSave) DoLSave.decompressIfNeeded({ state: save });

			const vars = save.history[save.index].variables;
			if (!vars.saveId) {
				// assign saveId, use Math.random() to not trip prng
				const saveId = Math.floor(Math.random() * 90000) + 10000;
				save.history.forEach(s => s.variables.saveId = saveId);
			}

			const details = {
				date: fullSaveObj.date,
				id: fullSaveObj.id,
				idx: fullSaveObj.idx,
				title: fullSaveObj.title,
				metadata: fullSaveObj.metadata || { saveId: vars.saveId, saveName: vars.saveName },
			};

			return [save, details];
		}

		let mtCount = 0;
		const oldSaves = Save.get();
		const autoSave = oldSaves.autosave;
		if (autoSave != null) {
			// autosave was moved from a separate slot in old system to just 0
			// if multiple autosaves are to be implemented, they can use negative slot numbers
			const saveData = processSave(save);
			// setItem only allows one operation at a time to prevent possible exploits, so wait for it to finish
			await setItem(0, saveData[0], { slot: 0, data: saveData[1] });
		} else mtCount++;
		for (let i = 0; i < oldSaves.slots.length; i++) {
			const slotSave = oldSaves.slots[i];
			if (slotSave != null) {
				const saveData = processSave(slotSave);
				await setItem(i + 1, saveData[0], { slot: i + 1, data: saveData[1] });
			} else mtCount++;
		}
		if (mtCount === oldSaves.slots.length + 1) { // all slots are empty, different storage method?
			const index = storage.get("index");
			if (index && index.slots) {
				// fc-like
				const autosave = storage.get("autosave");
				if (autosave) {
					const saveData = processSave(autosave);
					await setItem(0, saveData[0], { slot: 0, data: saveData[1] });
				}
				for (let i = 0; i < index.slots.length; i++) {
					const slotSave = storage.get("slot" + i); // eslint-disable-line prefer-template
					if (!slotSave) continue;
					const saveData = processSave(slotSave);
					await setItem(i + 1, saveData[0], { slot: i + 1, data: saveData[1] });
				}
			}
		}
		await getSaveDetails().then(d => _saveDetails = d);
		console.log("idb migration successful");
		return true;
	}

	/**
	 * turn transaction event handlers into promises
	 *
	 * @param {Request} transaction
	 */
	function makePromise(transaction) {
		return new Promise((resolve, reject) => {
			transaction.onsuccess = () => {
				_lock = false;
				return resolve(transaction.result);
			};
			transaction.oncomplete = () => {
				_lock = false;
				return resolve(transaction.result);
			};
			transaction.onerror = ev => {
				_lock = false;
				_active = false;
				console.log(transaction.error, ev, "error");
				return reject(transaction.error);
			};
			transaction.onabort = () => {
				_lock = false;
				console.log("aborted", transaction.error);
				return reject(transaction.error);
			};
		});
	}

	/**
	 * retrieve an item from indexedDB
	 *
	 * @param {number} slot
	 * @returns {Promise} promise to return a value some day
	 */
	async function getItem(slot) {
		const db = await openDB();
		const transactionRequest = db.transaction("saves", "readonly");
		const item = transactionRequest.objectStore("saves").get(slot);
		const result = await makePromise(item).catch(err => log(`couldn't retrieve idb item in slot ${slot}`, err));
		db.close();
		return result;
	}

	/**
	 * place a save object into saves store and a provided or calculated details object into details store
	 * will replace existing object in specified slot without a second thought
	 *
	 * @param {number} slot slot to write into
	 * @param {object} saveObj valid save object with unencoded history
	 * @param {object} details optional save details to override what's going into details store
	 * @returns {Promise | undefined} promise to report on success of this operation some day or return early
	 */
	async function setItem(slot, saveObj, details) {
		if (_lock) return;
		if (saveObj == null || !Object.hasOwn(saveObj, "history")) return false;
		_lock = true;

		// prepare save details
		const savesItem = { slot, data: saveObj };
		const saveVars = saveObj.history[saveObj.index].variables;
		const metadata = Object.assign({ saveId: saveVars.saveId, saveName: saveVars.saveName }, details?.metadata);
		details.metadata = metadata;
		const detailsItem = details
			? {
				slot,
				data: details,
			}
			: {
				slot,
				data: {
					id: Story.domId,
					idx: State.qc,
					title: Story.get(State.passage).description(),
					date: Date.now(),
					metadata,
				},
			};

		// expect failures here
		try {
			// sanitize complex data structures that can't be stored in idb
			let counter = 0; // only report problems for the first frame
			saveObj.history.forEach(s => {
				baddies.splice(0); // clear the baddies
				funNuke(s.variables, "", !counter++); // wrap up new baddies
				if (baddies.length) s.baddies = clone(baddies); // seal the records
			});
			if (_settings.useDelta && slot !== 0) {
				// compress the history, some games are really space-hungry
				// autosaves are exempt because performance reasons
				saveObj.delta = State.deltaEncode(saveObj.history);
				delete saveObj.history;
			}

			const db = await openDB();

			const transactionRequest = db.transaction(["saves", "details"], "readwrite");
			transactionRequest.objectStore("saves").delete(slot);
			transactionRequest.objectStore("saves").add(savesItem);
			transactionRequest.objectStore("details").delete(slot);
			transactionRequest.objectStore("details").add(detailsItem);

			const result = await makePromise(transactionRequest).catch(err => log(`couldn't put idb item in slot ${slot}`, err));
			db.close();
			return result;
		} catch (ex) {
			// admit the defeat and go home
			log(`idb.setItem failure unknown. Couldn't complete the save in slot ${slot}`);
			_lock = false;
			// return a promise, because some code down the line expects .then()
			return new Promise(resolve => resolve(false));
		}
	}

	/**
	 * delete save data in a specified slot
	 *
	 * @param {number} slot
	 * @returns {Promise | undefined} promise to report on success or return early
	 */
	async function deleteItem(slot) {
		if (_lock) return;
		const db = await openDB();

		_lock = true;
		const transactionRequest = db.transaction(["saves", "details"], "readwrite");
		transactionRequest.objectStore("saves").delete(slot);
		transactionRequest.objectStore("details").delete(slot);
		const result = await makePromise(transactionRequest);
		db.close();
		await getSaveDetails();
		return result;
	}

	/**
	 * actually load a save from idb
	 *
	 * @param {number} slot
	 */
	async function loadState(slot) {
		if (_lock) return;
		const data = await getItem(slot);
		if (data == null) return false;
		const state = data.data;
		// restore history
		if (state.delta) {
			state.history = State.deltaDecode(state.delta);
			delete state.delta;
		}
		// restore complex structures
		state.history.forEach(s => {
			if (s.baddies) {
				ekuNnuf(s.variables, s.baddies);
				delete s.baddies;
			}
		});
		// reconstruct fullSaveObj expected by onLoad
		const details = _saveDetails.find(d => d.slot === slot)?.data;
		state.idx = details.idx;
		const fullSaveObj = Object.assign({ state }, details);
		Save.onLoad.handlers.forEach(fn => fn(fullSaveObj));
		State.unmarshalForSave(state);
		State.show();
	}

	/**
	 * save current game into idb
	 *
	 * @param {number} slot
	 * @param {string} title
	 * @param {object} metadata
	 */
	async function saveState(slot, title, metadata) {
		if (_lock) return;

		// assign V.saveId if necessary
		if (!V.saveId) {
			const saveId = Math.floor(Math.random() * 90000) + 10000;
			V.saveId = saveId;
			State.history.forEach(s => s.variables.saveId = saveId);
		}

		// saveObj goes into saves db, the rest goes into details db
		const saveObj = State.marshalForSave();
		// we combine state and details into a single big object because that's what onSave expects
		const fullSaveObj = {
			state: saveObj,
			date: Date.now(),
			id: Story.domId,
			idx: State.qc,
			title: title || Story.get(State.passage).description(),
		};
		if (metadata != null) fullSaveObj.metadata = metadata;

		// run onSave handlers
		Save.onSave.handlers.forEach(fn => fn(fullSaveObj, { type: slot <= 0 ? "autosave" : "slot" }));

		// weird as object pointers are in js, it is now safe to remove .state from fullSaveObj, leaving only save details. so, let's rename it to reflect that
		const details = fullSaveObj;
		delete details.state;

		// finally, send everything to idb and synchronize _saveDetails
		if (saveObj != null) {
			await setItem(slot, saveObj, details);
			await getSaveDetails();
			return true;
		}
		return false;
	}

	/**
	 * retrieve details for all saves from idb and also cache them to _saveDetails for fast retrieval
	 *
	 * @returns {Promise<array>} list of details for all saves in idb
	 */
	async function getSaveDetails() {
		const db = await openDB();
		// const db = request.result;
		const transactionRequest = db.transaction(["details"], "readonly");
		const details = await makePromise(transactionRequest.objectStore("details").getAll());
		db.close();
		// warning: async quirks
		_saveDetails = details; // here, details is an array
		return details; // but the function returns a promise resolved to that array, not the array itself
	}

	/**
	 * get DATA for ALL saves in the db
	 * WILL fail if db is bigger than 2gb (and probably earlier)
	 *
	 * @returns {Array} list of data for all saves in idb
	 */
	async function getAllSaves() {
		const db = await openDB();
		const transactionRequest = db.transaction(["saves"], "readonly");
		const saves = transactionRequest.objectStore("saves").getAll();
		const result = await makePromise(saves)
		db.close();

		return result;
	}

	/**
	 * mercilessly clear all object stores one step short from outright deleting the db itself
	 *
	 * @returns {Promise | undefined} promise to maybe report when the deed is done or return early
	 */
	async function clearAll() {
		if (_lock) return;
		const db = await openDB();
		const transactionRequest = db.transaction(["saves", "details"], "readwrite");
		transactionRequest.objectStore("saves").clear();
		transactionRequest.objectStore("details").clear();
		_saveDetails = [];

		return makePromise(transactionRequest);
	}

	/**
	 * check if saves are allowed
	 */
	function savesAllowed() {
		return typeof Config.saves.isAllowed !== "function" || Config.saves.isAllowed();
	}

	/**
	 * define saveList variables
	 */

	let listLength; // store save list length in idb
	let listPage; // same with the current page
	const listLengthMax = 20; // maximum number of rows
	const listPageMax = 20; // maximum number of pages
	let latestSave = { slot: 1, date: 0 }; // keep track of the most recent save, separately from autosave on slot 0
	let extraSaveWarn;
	let footerHTML = ""; // add some text to fill empty space at the deleteAll button

	/**
	 * construct a saves list page, with configurable length
	 *
	 * @param {number} page
	 * @param {number} length
	 * @returns {DocumentFragment};
	 */
	function generateSavesPage(page = listPage - 1, length = listLength) {
		const listContainer = document.createElement("div");
		listContainer.id = "saves-list-container";
		listContainer.appendChild(generateHeaderRow());
		// cache whether saves are allowed
		const saveUnlock = savesAllowed();

		// find the most recent save that is not autosave
		latestSave = { slot: 1, date: 0 }; // re-init latest slot every time
		let autoSaveDate; // store timestamp for the autosave separately
		_saveDetails.forEach(d => {
			if (d.slot === 0) autoSaveDate = d.data.date;
			else if (d.data.date > latestSave.date) {
				latestSave.slot = d.slot;
				latestSave.date = d.data.date;
			}
		});
		// default list length is set here
		if (!listLength) {
			// idb is indexed by slot, so the highest is always last
			const slot = _saveDetails.length ? _saveDetails.last().slot : 0;
			// adjust list length to include saves in the highest slot
			// max pages is 20 (still too low if you're using 1080x1920 portrait mode)
			// by default, list length is 10, resulting into up to 200 slots across 20 pages
			// max list length is 20, resulting into up to 400 slots
			// having a save anywhere in slots > 200 shall increase list length so it won't disappear
			// it can also be used to increase default list length without any extra variables
			for (listLength = 10; slot > listLength * listPageMax && listLength < listLengthMax; listLength++);
			length = listLength;
		}
		// if not set to a correct value, show the page with the most recent save
		if (!Number.isInteger(page)) {
			// autosave is shown on every page, so if autosave is the most recent save - open the page with the most recent non-autosave with the same ID
			const latestSlot = _saveDetails.find(d => d.slot === latestSave.slot);
			if (latestSlot) {
				const autoSaveExists = Boolean(_saveDetails.find(d => d.slot === 0));
				const ignoreAutoSave = latestSlot.data.date > autoSaveDate || latestSlot.data.metadata.saveId === _saveDetails[0].data.metadata.saveId;
				if (!autoSaveExists || ignoreAutoSave) page = Math.floor((latestSave.slot - 1) / length);
				else page = 0;
			} else page = 0;
			listPage = page + 1;
		}

		// getSaveDetails can take longer to init listLength and listPage than it takes for their fields to be placed on page, gotta update them in such case
		const pageField = document.getElementById("pageNum");
		if (pageField != null) pageField.value = listPage;
		const lengthField = document.getElementById("pageLen");
		if (lengthField != null) lengthField.value = listLength;

		// default object details for an empty slot
		const defaultDetailsObj = { date: "", title: "", metadata: { saveId: "", saveName: "" } };

		// always show autosave on top
		const autoDetailsObj = _saveDetails.find(d => d.slot === 0)?.data ?? clone(defaultDetailsObj);
		if (autoSaveDate > latestSave.date) autoDetailsObj.latestSlot = true;
		autoDetailsObj.slot = 0;
		// don't show if autosaves are disabled by the engine
		if (Save.autosave.ok())	listContainer.appendChild(generateSaveRow(autoDetailsObj));

		// main loop for adding the save rows
		for (let slot = length * page + 1; slot < length * (page + 1) + 1; slot++) {
			// create default details
			let detailsObj = clone(defaultDetailsObj);
			// if a save exists in idb, replace the details with recorded ones
			const detailsIndex = _saveDetails.findIndex(d => d.slot === slot);
			if (detailsIndex !== -1) {
				detailsObj = _saveDetails[detailsIndex].data;
				// add a flag to highlight the most recent save
				if (Number(latestSave.slot) === slot) detailsObj.latestSlot = true;
			}
			detailsObj.slot = slot;
			detailsObj.saveUnlock = saveUnlock;
			listContainer.appendChild(generateSaveRow(detailsObj));
		}

		return listContainer;
	}

	/**
	 * construct the header row for the save list
	 * warning: unnecessarily complicated DOM manipulations
	 *
	 * @returns {DocumentFragment} header row
	 */
	function generateHeaderRow() {
		const frag = document.createDocumentFragment();
		const saveListHeader = document.createElement("div");
		saveListHeader.className = "savesListRow";
		frag.appendChild(saveListHeader);

		const headerSaveGroup = document.createElement("div");
		headerSaveGroup.className = "saveGroup";
		saveListHeader.appendChild(headerSaveGroup);

		const headerSaveId = document.createElement("div");
		headerSaveId.className = "saveId";
		headerSaveId.innerText = "#";
		headerSaveGroup.appendChild(headerSaveId);

		const headerSaveButton = document.createElement("div");
		headerSaveButton.className = "saveButton";
		headerSaveButton.innerText = L10n.get("savesHeaderSaveLoad");
		headerSaveGroup.appendChild(headerSaveButton);

		const headerSaveName = document.createElement("div");
		headerSaveName.className = "saveName";
		headerSaveName.innerText = L10n.get("savesHeaderIDName");
		headerSaveGroup.appendChild(headerSaveName);

		const headerSaveDetails = document.createElement("div");
		headerSaveDetails.className = "saveDetails";
		headerSaveDetails.innerText = L10n.get("savesHeaderDetails");
		headerSaveGroup.appendChild(headerSaveDetails);

		const headerDeleteButton = document.createElement("div");
		headerDeleteButton.className = "deleteButton";
		headerSaveGroup.appendChild(headerDeleteButton);

		return frag;
	}

	/**
	 * construct the footer row for the save list
	 * warning: unnecessarily complicated DOM manipulations
	 *
	 * @returns {HTMLUListElement} footer row
	 */
	function generateFooterRow() {
		const container = document.createElement("ul");
		container.className = "buttons";
		let li;

		// save to file button
		const exportButton = document.createElement("button");
		exportButton.id = "saves-export";
		exportButton.className = "ui-close";
		exportButton.innerText = L10n.get("savesLabelExport");
		if (savesAllowed()) {
			exportButton.onclick = () => Save.export();
			exportButton.classList.add("saveMenuButton");
		} else exportButton.disabled = true;
		li = document.createElement("li");
		li.appendChild(exportButton);
		container.appendChild(li);

		// save to clipboard button
		if (navigator.clipboard) {
			const toClipboardButton = document.createElement("button");
			toClipboardButton.id = "saves-toClipboard";
			toClipboardButton.className = "ui-close";
			toClipboardButton.innerText = L10n.get("savesLabelToClipboard");
			if (savesAllowed()) {
				toClipboardButton.onclick = () => {
					navigator.clipboard.writeText(Save.serialize());
					window.closeOverlay();
				};
				toClipboardButton.classList.add("saveMenuButton");
			} else toClipboardButton.disabled = true;
			li = document.createElement("li");
			li.appendChild(toClipboardButton);
			container.appendChild(li);
		}

		// load from file button
		const importButton = document.createElement("button");
		importButton.id = "saves-import";
		importButton.className = "saveMenuButton";
		importButton.innerText = L10n.get("savesLabelImport");
		importButton.onclick = () => {
			jQuery(document.createElement("input")).prop("type", "file").on("change", SugarCube.Save.import).trigger("click"); // gotta give it to anthaum for finding this
			window.closeOverlay();
		};
		li = document.createElement("li");
		li.appendChild(importButton);
		container.appendChild(li);

		// delete all saves button
		const clearAllButton = document.createElement("button");
		clearAllButton.className = "saves-clear saveMenuButton";
		clearAllButton.innerText = L10n.get("savesLabelClear");
		clearAllButton.onclick = () => saveList("confirm clear");
		li = document.createElement("li");
		li.appendChild(clearAllButton);
		container.appendChild(li);

		return container;
	}

	/**
	 * optional extra footer row
	 */
	function generateExtraFooterRow() {
		if (!footerHTML) return null;
		const container = document.createElement("ul");
		container.className = "buttons";
		container.innerHTML = footerHTML;

		return container;
	}

	/**
	 * all this to generate a single saves row from provided details
	 * pure js dom manipulations are ugly
	 *
	 * @param {object} details save details
	 * @returns {DocumentFragment}
	 */
	function generateSaveRow(details) {
		// save row to be returned
		const row = document.createElement("div");
		// add a fancy transition that would highlight the row with this id
		if (details.latestSlot && details.slot !== 0) row.id = "latestSaveRow";
		row.className = "savesListRow";

		// save group container
		const group = document.createElement("div");
		group.className = "saveGroup";

		// save ID
		const saveId = document.createElement("div");
		saveId.className = "saveId";
		saveId.innerText = details.slot === 0 ? "A" : details.slot;
		if (details.slot > listPageMax * listLengthMax || details.slot < 0) saveId.classList.add("red");

		// save/load buttons container
		const saveload = document.createElement("div");
		saveload.className = "saveButton";

		// save button
		const saveButton = document.createElement("button");
		saveButton.innerText = L10n.get("savesLabelSave");
		if (details.saveUnlock) {
			saveButton.className = "saveMenuButton";
			saveButton.onclick = () => saveList("confirm save", details);
		} else {
			saveButton.disabled = true;
		}

		// load button
		const loadButton = document.createElement("button");
		loadButton.innerText = L10n.get("savesLabelLoad");
		if (details.date) {
			loadButton.className = "saveMenuButton";
			loadButton.onclick = () => saveList("confirm load", details);
		} else {
			loadButton.disabled = true;
		}
		if (details.slot !== 0) saveload.appendChild(saveButton);
		saveload.appendChild(loadButton);

		// save name
		const saveName = document.createElement("div");
		saveName.className = "saveName";
		// highlight saves with currently loaded save's id
		if (V.saveId === details.metadata.saveId) saveName.classList.add("gold");
		saveName.innerText = details.metadata.saveName ? details.metadata.saveName.slice(0, 10) : details.metadata.saveId;

		// save details
		const saveDetails = document.createElement("div");
		saveDetails.className = "saveDetails";
		// description
		const description = document.createElement("span");
		description.innerText = details.title || "\xa0";
		// date stamp
		const date = document.createElement("span");
		date.className = "datestamp";
		if (details.date) {
			// highlight (most) recent save(s)
			if (details.latestSlot) date.classList.add("green");
			else if (details.date > Date.now() - 1800000) date.classList.add("gold");
			date.innerText = new Date(details.date).toLocaleString();
		} else date.innerText = "\xa0";
		saveDetails.appendChild(description);
		saveDetails.appendChild(date);

		// delete button
		const deleteButton = document.createElement("button");
		deleteButton.className = "deleteButton right";
		deleteButton.innerText = L10n.get("savesLabelDelete");
		if (details.date) {
			deleteButton.classList.add("saveMenuButton");
			deleteButton.onclick = () => saveList("confirm delete", details);
		} else {
			deleteButton.disabled = true;
		}

		group.append(saveId, saveload, saveName, saveDetails);
		row.appendChild(group);
		row.appendChild(deleteButton);

		return row;
	}

	/**
	 * @returns {HTMLUListElement}
	 */
	function generatePager() {
		const container = document.createElement("ul");
		container.className = "buttons";
		let li;

		li = document.createElement("li");
		li.append(L10n.get("savesPagerPage"));
		container.appendChild(li);

		// previous page button
		const prevPage = document.createElement("button");
		prevPage.append("<");
		if (listPage > 1) {
			prevPage.classList.add("saveMenuButton");
			prevPage.onclick = () => {
				--listPage;
				saveList("show saves");
			};
		} else prevPage.disabled = true;
		li = document.createElement("li");
		li.appendChild(prevPage);
		container.appendChild(li);

		// page number input
		const pageNum = document.createElement("input");
		Object.assign(pageNum, {
			id: "pageNum",
			type: "number",
			value: listPage,
			style: "width: 3em",
			min: 1,
			max: listPageMax,
			onchange: () => {
				listPage = Math.clamp(Math.round(pageNum.value), 1, listPageMax);
				saveList("show saves");
			},
		});
		container.appendChild(pageNum); // Not in a li to keep closer to buttons

		// next page button
		const nextPage = document.createElement("button");
		nextPage.append(">");
		if (listPage < listPageMax) {
			nextPage.classList.add("saveMenuButton");
			nextPage.onclick = () => {
				++listPage;
				saveList("show saves");
			};
		} else nextPage.disabled = true;
		nextPage.onclick = () => {
			if (listPage < listPageMax) listPage++;
			saveList("show saves");
		};
		li = document.createElement("li");
		li.appendChild(nextPage);
		container.appendChild(li);

		li = document.createElement("li");
		li.append(L10n.get("savesPagerSavesPerPage"));
		container.appendChild(li);

		// list length input
		const pageLen = document.createElement("input");
		Object.assign(pageLen, {
			id: "pageLen",
			type: "number",
			value: listLength,
			style: "width: 3em",
			min: 1,
			max: listLengthMax,
			onchange: () => {
				listLength = Math.clamp(pageLen.value, 1, listLengthMax);
				saveList("show saves");
			},
		});
		li = document.createElement("li");
		li.append(pageLen);
		container.appendChild(li);

		// jump to most recent save button
		const jumpToLatest = document.createElement("button");
		jumpToLatest.className = "saveMenuButton";
		jumpToLatest.innerText = L10n.get("savesPagerJump");
		jumpToLatest.onclick = () => {
			// potentially exploitable to allow saving to slots way above the limit, but the limit is arbitrary to begin with, and idb doesn't actually suffer one bit from going beyond that limit
			listPage = Math.floor((latestSave.slot - 1) / listLength + 1);
			saveList("show saves");
			setTimeout(() => {
				const el = document.getElementById("latestSaveRow");
				if (el != null) {
					el.classList.remove("jumpToSaveTransition");
					el.classList.add("jumpToSaveTransition");
				}
			}, Engine.minDomActionDelay + 100);
		};
		li = document.createElement("li");
		li.appendChild(jumpToLatest);
		container.appendChild(li);

		return container;
	}

	// itch app must die or at least update to kitch version, smh
	const replaceChildren = !!document.body.replaceChildren;

	// alias for closing the saves menu
	if (typeof window.closeOverlay === "undefined") window.closeOverlay = Dialog.close;

	/**
	 * replace contents of saveList div with something useful
	 *
	 * @param {string} mode switch for displaying saves list or confirmations
	 * @param {object} details save details for confirmations
	 */
	async function saveList(mode, details) {
		if (_active && !_settings.active) updateSettings("active", true); // for when it's called from old save menu
		if (!mode) {
			// update saveDetails every time menu opens with no options, in case game was saved in another tab
			await getSaveDetails();
			mode = "show saves";
		}

		await new Promise(r => setTimeout(() => r(true), 0)); // this actually ensures that #saveList had time to render into DOM
		const savesDiv = document.getElementById("saveList") || document.getElementsByClassName("saveList")[0] || document.getElementsByClassName("saves")[0];
		const list = document.createDocumentFragment();

		// prepare a re-usable cancel button
		const cancelButton = document.createElement("button");
		cancelButton.className = "saveMenuButton saveMenuConfirm";
		cancelButton.innerText = L10n.get("cancel");
		cancelButton.onclick = () => saveList("show saves");

		// prepare old save info (if provided)
		function generateOldSaveDescription(details) {
			const oldSaveDescription = document.createDocumentFragment();
			if (!details || !details.date) return oldSaveDescription;

			const oldSaveTitle = document.createElement("p");
			oldSaveTitle.innerText = `${L10n.get("savesDescTitle")} ${details.title}`;

			const oldSaveData = document.createElement("p");
			oldSaveData.innerText = `${details.metadata.saveName ? L10n.get("savesDescName") + details.metadata.saveName : L10n.get("savesDescId") + details.metadata.saveId} ${L10n.get("savesDescDate")} ${new Date(details.date).toLocaleString()}`;

			oldSaveDescription.append(oldSaveTitle, oldSaveData);

			return oldSaveDescription;
		}

		switch (mode) {
			case "show saves": {
				// print saves list
				// show the warnings
				if (!savesAllowed()) {
					const notAllowedWarning = document.createElement("h3");
					notAllowedWarning.className = "red";
					notAllowedWarning.innerText = V.replayScene ? L10n.get("savesDisallowedReplay") : L10n.get("savesDisallowed");
					list.appendChild(notAllowedWarning);
				}

				const exportReminder = document.createElement("p");
				exportReminder.id = "saves-export-reminder";
				exportReminder.innerText = L10n.get("savesExportReminder");
				list.appendChild(exportReminder);

				// extra saves warning
				if (extraSaveWarn) {
					const lostSaves = document.createElement("p");
					lostSaves.innerHTML = "<i class=\"description\"><u>Where are my saves?</u></i> ";
					const lostSavesTooltip = document.createElement("mouse");
					lostSavesTooltip.classList.add("tooltip", "linkBlue");
					lostSavesTooltip.innerText = "(?)";
					lostSavesTooltip.appendChild(document.createElement("span"));
					lostSavesTooltip.lastChild.innerText = "If you can't find your saves, it's possible you saved them using a different storage method. Try toggling the \"Use old legacy storage\" option below the saves list.";
					lostSaves.appendChild(lostSavesTooltip);
					list.appendChild(lostSaves);
				}

				// THE SAVES LIST
				list.appendChild(generateSavesPage());

				// button row
				list.appendChild(generateFooterRow());

				// optional footer row
				if (footerHTML) list.appendChild(generateExtraFooterRow());

				// add pager
				list.appendChild(generatePager());

				// add confirmation toggles
				let ul = document.createElement("ul");
				ul.className = "buttons";
				let li;
				li = document.createElement("li");
				li.append(L10n.get("savesOptionsConfirmOn"));
				ul.appendChild(li);

				const reqSaveLabel = document.createElement("label");
				reqSaveLabel.innerText = L10n.get("savesOptionsOverwrite");
				const reqSave = document.createElement("input");
				reqSave.type = "checkbox";
				reqSave.checked = _settings.warnSave;
				reqSave.onchange = () => updateSettings("warnSave", reqSave.checked);
				reqSaveLabel.appendChild(reqSave);
				li = document.createElement("li");
				li.appendChild(reqSaveLabel);
				ul.appendChild(li);

				const reqLoadLabel = document.createElement("label");
				reqLoadLabel.innerText = L10n.get("savesLabelLoad");
				const reqLoad = document.createElement("input");
				reqLoad.type = "checkbox";
				reqLoad.checked = _settings.warnLoad;
				reqLoad.onchange = () => updateSettings("warnLoad", reqLoad.checked);
				reqLoadLabel.appendChild(reqLoad);
				li = document.createElement("li");
				li.appendChild(reqLoadLabel);
				ul.append("|", li);

				const reqDeleteLabel = document.createElement("label");
				reqDeleteLabel.innerText = L10n.get("savesLabelDelete");
				const reqDelete = document.createElement("input");
				reqDelete.type = "checkbox";
				reqDelete.checked = _settings.warnDelete;
				reqDelete.onchange = () => updateSettings("warnDelete", reqDelete.checked);
				reqDeleteLabel.appendChild(reqDelete);
				li = document.createElement("li");
				li.appendChild(reqDeleteLabel);
				ul.append("|", li);

				// last element gets floated to the right. empty one doesn't matter
				ul.append(document.createElement("li"));

				list.append(ul);

				// add instant idb switcher
				ul = document.createElement("ul");
				ul.className = "buttons";
				const idbtoggle = document.createElement("button");
				idbtoggle.id = "saves-idb-toggle";
				idbtoggle.className = "saveMenuButton";
				idbtoggle.innerText = L10n.get("savesOptionsUseLegacy");
				idbtoggle.onclick = () => {
					updateSettings("active", false);
					if (window.DoLSave)	$.wiki("<<replace #saveList>><<saveList>><</replace>>");
					else UI.buildSaves();
				};
				li = document.createElement("li");
				li.appendChild(idbtoggle);
				ul.appendChild(li);
				list.appendChild(ul);

				setTimeout(() => {
					if (replaceChildren) savesDiv.replaceChildren(list);
					else { // curse you, itch app!
						savesDiv.innerHTML = "";
						savesDiv.appendChild(list);
					}
					const pageField = document.getElementById("pageNum");
					if (pageField != null) pageField.value = listPage;
					const lengthField = document.getElementById("pageLen");
					if (lengthField != null) lengthField.value = listLength;
					Dialog.resize(); // fix dialog size
				}, Engine.minDomActionDelay);
				break;
			}
			case "confirm save": {
				// skip confirmation if the slot is empty, but do not skip on saveId mismatch, even if confirmation is not required
				if (!details.date || !_settings.warnSave && details.metadata.saveId === V.saveId) return saveState(details.slot).then(window.closeOverlay());
				const confirmSaveWarning = document.createElement("div");
				confirmSaveWarning.className = "saveBorder";

				const confirmSaveWarningTitle = document.createElement("h3");
				confirmSaveWarningTitle.className = "red";
				confirmSaveWarningTitle.innerText = `${details.date === "" ? L10n.get("savesWarningSaveOnSlot") : L10n.get("savesWarningOverwriteSlot")} ${details.slot}?`;

				if (details.date && V.saveId !== details.metadata.saveId) {
					const overwriteWarning = document.createElement("span");
					overwriteWarning.className = "red";
					overwriteWarning.innerText = L10n.get("savesWarningOverwriteID");
				}

				const saveButton = document.createElement("input");
				Object.assign(saveButton, {
					type: "button",
					className: "saveMenuButton saveMenuConfirm",
					value: L10n.get("savesLabelSave"),
					onclick: () => saveState(details.slot).then(() => window.closeOverlay()),
				});
				confirmSaveWarning.append(confirmSaveWarningTitle, generateOldSaveDescription(details), saveButton, cancelButton);

				list.appendChild(confirmSaveWarning);
				setTimeout(() => {
					if (replaceChildren) savesDiv.replaceChildren(list);
					else { // curse you, itch app!
						savesDiv.innerHTML = "";
						savesDiv.appendChild(list);
					}
				}, Engine.minDomActionDelay);
				break;
			}
			case "confirm delete": {
				// skip confirmation if corresponding toggle is off
				if (!_settings.warnDelete) return deleteItem(details.slot).then(() => saveList("show saves"));
				const confirmDeleteWarning = document.createElement("div");
				confirmDeleteWarning.className = "saveBorder";
				const confirmDeleteWarningTitle = document.createElement("h3");
				confirmDeleteWarningTitle.className = "red";
				confirmDeleteWarningTitle.innerText = `${L10n.get("savesWarningDeleteInSlot") + (details.slot === 0 ? "auto" : details.slot)}?`;

				const deleteButton = document.createElement("input");
				Object.assign(deleteButton, {
					type: "button",
					className: "saveMenuButton saveMenuConfirm",
					value: L10n.get("savesLabelDelete"),
					onclick: () => deleteItem(details.slot).then(() => saveList("show saves")),
				});

				confirmDeleteWarning.append(confirmDeleteWarningTitle, generateOldSaveDescription(details), deleteButton, cancelButton);

				list.appendChild(confirmDeleteWarning);
				setTimeout(() => {
					if (replaceChildren) savesDiv.replaceChildren(list);
					else { // curse you, itch app!
						savesDiv.innerHTML = "";
						savesDiv.appendChild(list);
					}
				}, Engine.minDomActionDelay);
				break;
			}
			case "confirm load": {
				// skip confirmation if corresponding toggle is off
				if (!_settings.warnLoad) return loadState(details.slot).then(() => window.closeOverlay());
				const confirmLoad = document.createElement("div");
				confirmLoad.className = "saveBorder";
				const confirmLoadTitle = document.createElement("h3");
				confirmLoadTitle.className = "red";
				confirmLoadTitle.innerText = `${L10n.get("savesWarningLoad") + (details.slot === 0 ? "auto" : details.slot)}?`;

				const loadButton = document.createElement("input");
				Object.assign(loadButton, {
					type: "button",
					className: "saveMenuButton saveMenuConfirm",
					value: L10n.get("savesLabelLoad"),
					onclick: () => idb.loadState(details.slot).then(() => window.closeOverlay()),
				});
				confirmLoad.append(confirmLoadTitle, generateOldSaveDescription(details), loadButton, cancelButton);

				list.appendChild(confirmLoad);
				setTimeout(() => {
					if (replaceChildren) savesDiv.replaceChildren(list);
					else { // curse you, itch app!
						savesDiv.innerHTML = "";
						savesDiv.appendChild(list);
					}
				}, Engine.minDomActionDelay);
				break;
			}
			case "confirm clear": {
				// storage wipes always require confirmation
				const confirmClear = document.createElement("div");
				confirmClear.className = "saveBorder";
				const confirmClearTitle = document.createElement("h2");
				confirmClearTitle.className = "red";
				confirmClearTitle.innerText = L10n.get("savesWarningDeleteAll");

				const clearButton = document.createElement("input");
				Object.assign(clearButton, {
					type: "button",
					className: "saveMenuButton saveMenuConfirm",
					value: L10n.get("savesLabelClear"),
					onclick: () => clearAll().then(() => saveList("show saves")),
				});
				confirmClear.append(confirmClearTitle, clearButton, cancelButton);

				list.appendChild(confirmClear);
				setTimeout(() => {
					if (replaceChildren) savesDiv.replaceChildren(list);
					else { // curse you, itch app!
						savesDiv.innerHTML = "";
						savesDiv.appendChild(list);
					}
				}, Engine.minDomActionDelay);
				break;
			}
		}
	}

	return Object.freeze(Object.defineProperties({}, {
		/* eslint-disable brace-style */
		dbName:         { get() { return _dbName;    }, set(val) { _dbName = val; } },
		lock:           { get() { return _lock;      }, set(val) { _lock = Boolean(val); } },
		active:         { get() { return _active;    }, set(val) { _active = Boolean(val); } },
		listLength:     { get() { return listLength; }, set(val) { listLength = val; } },
		listPage:       { get() { return listPage;   }, set(val) { listPage = val; } },
		footerHTML:     { get() { return footerHTML; }, set(val) { footerHTML = val; } },
		baddies:        { get() { return baddies; } },
		init:           { value(dbName) { return openDB(dbName); } },
		getSaveDetails: { value: getSaveDetails },
		getAllSaves:    { value: getAllSaves },
		saveList:       { value: saveList },
		saveState:      { value: saveState },
		loadState:      { value: loadState },
		setItem:        { value: setItem },
		getItem:        { value: getItem },
		deleteItem:     { value: deleteItem },
		clearAll:       { value: clearAll },
		updateSettings: { value: updateSettings },
		funNuke:        { value: funNuke },
		ekuNnuf:        { value: ekuNnuf },
		importFromLocalStorage: { value: importFromLocalStorage },
	}));
})();
window.idb = idb;


code:
/***********************************************************************************************************************

	l10n/legacy.js

	Copyright © 2013–2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/

/*
	[DEPRECATED] The `strings` object is deprecated and should no longer be used.
	All new or updated translations should be based upon the `l10nStrings` object
	(see: `l10n/strings.js`).

	Legacy/existing uses of the `strings` object will be mapped to the `l10nStrings`
	object after user script evaluation.
*/
var strings = { // eslint-disable-line no-unused-vars, no-var
	errors    : {},
	warnings  : {},
	debugView : {},
	uiBar     : {},
	jumpto    : {},
	saves     : {},
	settings  : {},
	restart   : {},
	share     : {},
	autoload  : {},
	macros    : {
		back   : {},
		return : {}
	}
};


code:
/***********************************************************************************************************************

	l10n/strings.js

	Copyright © 2013–2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/
/* eslint-disable max-len, prefer-template */

/*
	ATTENTION TRANSLATORS

	Please use the `locale/l10n-template.js` file, from the root of the repository,
	as the template for your translation rather than this file.

	SEE: https://github.com/tmedwards/sugarcube-2/tree/develop/locale
*/
var l10nStrings = { // eslint-disable-line no-unused-vars, no-var
	/*
		General.
	*/
	identity : 'game',
	aborting : 'Aborting',
	cancel   : 'Cancel',
	close    : 'Close',
	ok       : 'OK',

	/*
		Errors.
	*/
	errorTitle              : 'Error',
	errorToggle             : 'Toggle the error view',
	errorNonexistentPassage : 'the passage "{passage}" does not exist', // NOTE: `passage` is supplied locally
	errorSaveDiskLoadFailed : 'failed to load save file from disk',
	errorSaveMissingData    : 'save is missing required data. Either the loaded file is not a save or the save has become corrupted',
	errorSaveIdMismatch     : 'save is from the wrong {identity}',

	/*
		Warnings.
	*/
	_warningIntroLacking  : 'Your browser either lacks or has disabled',
	_warningOutroDegraded : ', so this {identity} is running in a degraded mode. You may be able to continue, however, some parts may not work properly.',
	warningNoWebStorage   : '{_warningIntroLacking} the Web Storage API{_warningOutroDegraded}',
	warningDegraded       : '{_warningIntroLacking} some of the capabilities required by this {identity}{_warningOutroDegraded}',

	/*
		Debug bar.
	*/
	debugBarToggle      : 'Toggle the debug bar',
	debugBarNoWatches   : '\u2014 no watches set \u2014',
	debugBarAddWatch    : 'Add watch',
	debugBarDeleteWatch : 'Delete watch',
	debugBarWatchAll    : 'Watch all',
	debugBarWatchNone   : 'Delete all',
	debugBarLabelAdd    : 'Add',
	debugBarLabelWatch  : 'Watch',
	debugBarLabelTurn   : 'Turn', // (noun) chance to act (in a game), moment, period
	debugBarLabelViews  : 'Views',
	debugBarViewsToggle : 'Toggle the debug views',
	debugBarWatchToggle : 'Toggle the watch panel',

	/*
		UI bar.
	*/
	uiBarToggle   : 'Toggle the UI bar',
	uiBarBackward : 'Go backward within the {identity} history',
	uiBarForward  : 'Go forward within the {identity} history',
	uiBarJumpto   : 'Jump to a specific point within the {identity} history',

	/*
		Jump To.
	*/
	jumptoTitle       : 'Jump To',
	jumptoTurn        : 'Turn', // (noun) chance to act (in a game), moment, period
	jumptoUnavailable : 'No jump points currently available\u2026',

	/*
		Saves.
	*/
	savesTitle       : 'Saves',
	savesDisallowed  : 'You can\'t save on this passage!',
	savesIncapable   : '{_warningIntroLacking} the capabilities required to support saves, so saves have been disabled for this session.',
	savesLabelAuto   : 'Autosave',
	savesLabelDelete : 'Delete ',
	savesLabelExport : 'Save to File\u2026',
	savesLabelImport : 'Load from File\u2026',
	savesLabelLoad   : 'Load ',
	savesLabelClear  : 'Delete All',
	savesLabelSave   : 'Save',
	savesLabelSlot   : 'Slot',
	savesUnavailable : 'No save slots found\u2026',
	savesUnknownDate : 'unknown',

	/*
		idb related
	*/
	savesDisallowedReplay     : 'The scene viewer is currently in use, preventing the use of the save system.',
	savesExportReminder       : 'Warning: If your browser cache is cleared, saves here will be lost! Consider saving to file every so often!',
	savesHeaderSaveLoad       : 'Save/Load',
	savesHeaderIDName         : 'ID/Name',
	savesHeaderDetails        : 'Details',
	savesDescTitle            : 'Title: ',
	savesDescName             : 'Save Name: ',
	savesDescId               : 'Save Id: ',
	savesDescDate             : 'Date: ',
	savesPagerJump            : 'Jump to most recent manual save',
	savesPagerPage            : 'Page:',
	savesPagerSavesPerPage    : 'Saves per page:',
	savesOptionsConfirmOn     : 'Require confirmation on:',
	savesOptionsOverwrite     : 'Overwrite ',
	savesWarningSaveOnSlot    : 'Save on slot',
	savesWarningOverwriteSlot : 'Overwrite save in slot',
	savesWarningOverwriteID   : 'Save ID does not match, continue with overwrite?',
	savesWarningDeleteInSlot  : 'Delete save in slot',
	savesWarningLoad          : 'Load slot',
	savesWarningDeleteAll     : 'WARNING - DO YOU REALLY WANT TO DELETE ALL SAVES?',
	savesLabelToClipboard     : 'Save to Clipboard\u2026',

	/*
		Settings.
	*/
	settingsTitle : 'Settings',
	settingsOff   : 'Off',
	settingsOn    : 'On',
	settingsReset : 'Reset to Defaults',

	/*
		Restart.
	*/
	restartTitle  : 'Restart',
	restartPrompt : 'Are you sure that you want to restart? Unsaved progress will be lost.',

	/*
		Share.
	*/
	shareTitle : 'Share',

	/*
		Alert.
	*/
	alertTitle : 'Alert',

	/*
		Autoload.
	*/
	autoloadTitle  : 'Autoload',
	autoloadCancel : 'Go to start',
	autoloadOk     : 'Load autosave',
	autoloadPrompt : 'An autosave exists. Load it now or go to the start?',

	/*
		Macros.
	*/
	macroBackText   : 'Back',  // (verb) rewind, revert
	macroReturnText : 'Return' // (verb) go/send back
};


code:
/***********************************************************************************************************************

	lib/alert.js

	Copyright © 2013–2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/

/*
	TODO: This regular expression should be elsewhere.

	Error prologs by engine/browser: (ca. 2018)
		Chrome, Opera, & Vivaldi → `Uncaught \w*Error: …`
		Edge & IE                → `…`
		Firefox                  → `Error: …`
		Opera (Presto)           → `Uncaught exception: \w*(?:Error|Exception): …`
		Safari (ca. v5.1)        → `\w*(?:Error|_ERR): …`
*/
var errorPrologRegExp = /^(?:(?:uncaught\s+(?:exception:\s+)?)?\w*(?:error|exception|_err):\s+)+/i; // eslint-disable-line no-unused-vars, no-var

var Alert = (() => { // eslint-disable-line no-unused-vars, no-var
	'use strict';

	/*******************************************************************************************************************
		Error Functions.
	*******************************************************************************************************************/
	function mesg(where, error, isFatal, isUncaught) {
		let mesg = 'Error';
		let nice = `A${isFatal ? ' fatal' : 'n'} error has occurred.`;

		if (isFatal) {
			nice += ' Aborting.';
		}
		else {
			nice += ' You may be able to continue, but some parts may not work properly.';
		}

		const isObject = error !== null && typeof error === 'object';
		const isExLike = isObject && 'message' in error;
		const what     = (
			isExLike ? String(error.message).replace(errorPrologRegExp, '') : String(error)
		).trim() || 'unknown error';

		if (where != null) { // lazy equality for null
			mesg += ` [${where}]`;
		}

		mesg += `: ${what}.`;

		if (isObject && 'stack' in error) {
			mesg += `\n\nStack Trace:\n${error.stack}`;
		}

		if (mesg) {
			nice += `\n\n${mesg}`;
		}

		// Log a plain message.
		if (!isUncaught) {
			console[isFatal ? 'error' : 'warn'](mesg);
		}

		// Pop up a nice message.
		window.alert(nice); // eslint-disable-line no-alert
	}

	function alertError(where, error) {
		mesg(where, error);
	}

	function alertFatal(where, error) {
		mesg(where, error, true);
	}

	/*******************************************************************************************************************
		Error Event.
	*******************************************************************************************************************/
	/*
		Set up a global error handler for uncaught exceptions.
	*/
	(origOnError => {
		window.onerror = function (what, source, lineNum, colNum, error) {
			// console.error(what, source, lineNum, colNum, error);

			// Uncaught exceptions during play may be recoverable/ignorable.
			if (document.readyState === 'complete') {
				mesg(null, error != null ? error : what, false, true); // lazy equality for null
			}

			// Uncaught exceptions during startup should be fatal.
			else {
				mesg(null, error != null ? error : what, true, true); // lazy equality for null
				window.onerror = origOnError;

				if (typeof window.onerror === 'function') {
					window.onerror.apply(this, arguments);
				}
			}
		};
	})(window.onerror);

	/*******************************************************************************************************************
		Module Exports.
	*******************************************************************************************************************/
	return Object.freeze(Object.defineProperties({}, {
		error : { value : alertError },
		fatal : { value : alertFatal }
	}));
})();


code:
/***********************************************************************************************************************

	lib/browser.js

	Copyright © 2013–2021 Thomas Michael Edwards <thomasmedwards@gmail.com>. All rights reserved.
	Use of this source code is governed by a BSD 2-clause "Simplified" License, which may be found in the LICENSE file.

***********************************************************************************************************************/

var Browser = (() => { // eslint-disable-line no-unused-vars, no-var
	'use strict';

	/* eslint-disable max-len */
	const userAgent = navigator.userAgent.toLowerCase();

	const winPhone = userAgent.includes('windows phone');
	const isMobile = Object.freeze({
		Android    : !winPhone && userAgent.includes('android'),
		BlackBerry : /blackberry|bb10/.test(userAgent),
		iOS        : !winPhone && /ip(?:hone|ad|od)/.test(userAgent),
		Opera      : !winPhone && (typeof window.operamini === 'object' || userAgent.includes('opera mini')),
		Windows    : winPhone || /iemobile|wpdesktop/.test(userAgent),

		any() {
			return isMobile.Android || isMobile.BlackBerry || isMobile.iOS || isMobile.Opera || isMobile.Windows;
		}
	});

	const isGecko = !isMobile.Windows && !/khtml|trident|edge/.test(userAgent) && userAgent.includes('gecko');

	const isIE      = !userAgent.includes('opera') && /msie|trident/.test(userAgent);
	const ieVersion = isIE
		? (() => {
			const ver = /(?:msie\s+|rv:)(\d+\.\d)/.exec(userAgent);
			return ver ? Number(ver[1]) : 0;
		})()
		: null;

	// opera <= 12: "opera/9.80 (windows nt 6.1; wow64) presto/2.12.388 version/12.16"
	// opera >= 15: "mozilla/5.0 (windows nt 6.1; wow64) applewebkit/537.36 (khtml, like gecko) chrome/28.0.1500.52 safari/537.36 opr/15.0.1147.130"
	const isOpera      = userAgent.includes('opera') || userAgent.includes(' opr/');
	const operaVersion = isOpera
		? (() => {
			const re  = new RegExp(`${/khtml|chrome/.test(userAgent) ? 'opr' : 'version'}\\/(\\d+\\.\\d+)`);
			const ver = re.exec(userAgent);
			return ver ? Number(ver[1]) : 0;
		})()
		: null;

	const isVivaldi = userAgent.includes('vivaldi');
	/* eslint-enable max-len */

	// Module Exports.
	return Object.freeze({
		userAgent,
		isMobile,
		isGecko,
		isIE,
		ieVersion,
		isOpera,
		operaVersion,
		isVivaldi
	});
})();