diff --git a/xtrn/slyvote/readme.txt b/xtrn/slyvote/readme.txt
index 332d6bc4d5f38f1addeb6e6ec774a6919f333bf2..cfd776dd99a612545e7eaa3382becd83a42eea0b 100644
--- a/xtrn/slyvote/readme.txt
+++ b/xtrn/slyvote/readme.txt
@@ -1,5 +1,5 @@
                                    SlyVote
-                                 Version 1.14
+                                 Version 1.15
                            Release date: 2023-09-20
 
                                      by
@@ -98,15 +98,13 @@ SlyVote is comprised of the following files:
 
 2. slyvote.cfg            The SlyVote configuration file
 
-3. dd_lightbar_menu.js    A JavaScript lightbar menu class used by SlyVote.
-                          You do not need to edit or use this file directly.
-                          You can keep this file in the SlyVote directory or
-                          copy it to your sbbs/exec/load directory if it's
-                          not already there.  This file has been added to
-                          Synchronet's CVS source code repository in
-                          sbbs/exec/load, so it may already be part of your
-                          Synchronet setup if you have updated your JavaScript
-                          files.
+3. slyv_cfg.js            A menu-driven configuration program for SlyVote. As an
+                          alternative to editing slyvote.cfg, this configurator
+                          can be run at the command prompt in the SlyVote
+                          directory with the following command:
+                          jsexec slyv_cfg
+                          Alternately:
+                          jsexec slyv_cfg.js
 
 slyvote.cfg is a plain text file, so it can be edited using any text editor.
 
diff --git a/xtrn/slyvote/slyv_cfg.js b/xtrn/slyvote/slyv_cfg.js
new file mode 100644
index 0000000000000000000000000000000000000000..15f1d8158dba56b62b88ab7ab1689320813f5bee
--- /dev/null
+++ b/xtrn/slyvote/slyv_cfg.js
@@ -0,0 +1,611 @@
+// This is a menu-driven configuration program/script for SlyVote.  If you're running SlyVote from
+// xtrn/slyvote (the standard location), then any changes are saved to slyvote.cfg in
+// sbbs/mods, so that custom changes don't get overridden due to an update.
+// If you have SlyVote in a directory other than xtrn/slyvote, then the changes to
+// slyvote.cfg will be saved in that directory (assuming you're running slyv_cfg.js from
+// that same directory).
+//
+// If you're running SlyVote from xtrn/slyvote (the standard location) and you want
+// to save the configuration file there (rather than sbbs/mods), you can use one of the
+// following command-line options: noMods, -noMods, no_mods, or -no_mods
+
+require("sbbsdefs.js", "P_NONE");
+require("uifcdefs.js", "UIFC_INMSG");
+
+
+if (!uifc.init("SlyVote 1.15 Configurator"))
+{
+	print("Failed to initialize uifc");
+	exit(1);
+}
+js.on_exit("uifc.bail()");
+
+
+var gCfgFilename = "slyvote.cfg";
+
+// Read the configuration file
+var gCfgInfo = readCfgFile();
+if (!gCfgInfo.successfullyOpened)
+{
+	writeln("");
+	writeln("* Could not open the configuration file!");
+	writeln("");
+	exit(1);
+}
+
+var gHelpWrapWidth = uifc.screen_width - 10;
+// A string to use in the main menu for enabled sub-boards when there are sub-boards configured
+var gAllowedSubsCfgMenuText = "<Subs set>";
+
+// When saving the configuration file, always save it in the same directory
+// as SlyVote (or this script); don't copy to mods
+var gAlwaysSaveCfgInOriginalDir = false;
+
+// Parse command-line arguments
+parseCmdLineArgs();
+
+// Read the SlyVote configuration file
+var gCfgInfo = readCfgFile();
+
+
+// Show the main menu and go from there.
+// This is in a loop so that if the user aborts from confirming to save
+// settings, they'll return to the main menu.
+var anyOptionChanged = false;
+var continueOn = true;
+while (continueOn)
+{
+	anyOptionChanged = doMainMenu() || anyOptionChanged;
+	// If any option changed, then let the user save the configuration if they want to
+	if (anyOptionChanged)
+	{
+		var userChoice = promptYesNo("Save configuration?", true, WIN_ORG|WIN_MID|WIN_ACT|WIN_ESC);
+		if (typeof(userChoice) === "boolean")
+		{
+			if (userChoice)
+			{
+				var saveRetObj = saveCfgFile();
+				// Show an error if failed to save the settings.  If succeeded, only show
+				// a message if settings were saved to the mods directory.
+				if (!saveRetObj.saveSucceeded)
+					uifc.msg("Failed to save settings!");
+				else
+				{
+					if (saveRetObj.savedToModsDir)
+						uifc.msg("Changes were successfully saved (to the mods dir)");
+				}
+			}
+			continueOn = false;
+		}
+	}
+	else
+		continueOn = false;
+}
+
+
+
+///////////////////////////////////////////////////////////
+// Functions
+
+// Parses command-line arguments & sets any applicable values
+function parseCmdLineArgs()
+{
+	for (var i = 0; i < argv.length; ++i)
+	{
+		var argUpper = argv[i].toUpperCase();
+		if (argUpper == "NOMODS" || argUpper == "NO_MODS" || argUpper == "-NOMODS" || argUpper == "-NO_MODS")
+			gAlwaysSaveCfgInOriginalDir = true;
+	}
+}
+
+function doMainMenu()
+{
+	// For menu item text formatting
+	var itemTextMaxLen = 50;
+
+	// Configuration option object properties
+	// cfgOptProps must correspond exactly with optionStrs & menuItems
+	var cfgOptProps = [
+		"showAvatars", // Boolean
+		"useAllAvailableSubBoards", // Boolean
+		"startupSubBoardCode", // String
+		"subBoardCodes" // String (comma-separated list)
+	];
+	// Strings for the options to display on the menu
+	var optionStrs = [
+		"Show avatars in poll messages",
+		"Use all available sub-boards",
+		"Startup sub-board",
+		"List of sub-boards to allow"
+	];
+	// Build an array of formatted string to be displayed on the menu
+	// (the value formatting will depend on the variable type)
+	var menuItems = [];
+	for (var i = 0; i < cfgOptProps.length; ++i)
+	{
+		var propName = cfgOptProps[i];
+		if (propName == "subBoardCodes")
+			menuItems.push(formatCfgMenuText(itemTextMaxLen, optionStrs[i], gCfgInfo.cfgOptions.subBoardCodes != "" ? gAllowedSubsCfgMenuText : ""));
+		else
+			menuItems.push(formatCfgMenuText(itemTextMaxLen, optionStrs[i], gCfgInfo.cfgOptions[propName]));
+	}
+
+	// Help text
+	// A dictionary of help text for each option, indexed by the option name from the configuration file
+	if (doMainMenu.optHelp == undefined)
+		doMainMenu.optHelp = getOptionHelpText();
+	if (doMainMenu.mainScreenHelp == undefined)
+		doMainMenu.mainScreenHelp = getMainHelp(doMainMenu.optHelp, cfgOptProps);
+
+	// Create a CTX to specify the current selected item index
+	if (doMainMenu.ctx == undefined)
+		doMainMenu.ctx = uifc.list.CTX();
+	// Selection
+	var winMode = WIN_ORG|WIN_MID|WIN_ACT|WIN_ESC;
+	var menuTitle = "SlyVote Configuration";
+	var anyOptionChanged = false;
+	var continueOn = true;
+	while (continueOn && !js.terminated)
+	{
+		uifc.help_text = doMainMenu.mainScreenHelp;
+		var optionMenuSelection = uifc.list(winMode, menuTitle, menuItems, doMainMenu.ctx);
+		doMainMenu.ctx.cur = optionMenuSelection; // Remember the current selected item
+		if (optionMenuSelection == -1) // ESC
+			continueOn = false;
+		else
+		{
+			var optName = cfgOptProps[optionMenuSelection];
+			var itemType = typeof(gCfgInfo.cfgOptions[optName]);
+			uifc.help_text = doMainMenu.optHelp[optName];
+			if (optName == "startupSubBoardCode")
+			{
+				// Startup sub-board code
+				// displayMsgGrpsAndChooseSubBoard() will return undefined if the user aborted
+				var subCode = displayMsgGrpsAndChooseSubBoard();
+				if (typeof(subCode) === "string")
+				{
+					if (subCode != "" && subCode != gCfgInfo.cfgOptions.startupSubBoardCode)
+					{
+						anyOptionChanged = true;
+						gCfgInfo.cfgOptions.startupSubBoardCode = subCode;
+						menuItems[optionMenuSelection] = formatCfgMenuText(itemTextMaxLen, optionStrs[optionMenuSelection], gCfgInfo.cfgOptions[optName]);
+					}
+				}
+				else
+				{
+					if (gCfgInfo.cfgOptions.startupSubBoardCode != "")
+					{
+						var userChoice = promptYesNo("Clear the startup sub-board?", true, WIN_ORG|WIN_MID|WIN_ACT|WIN_ESC);
+						if (typeof(userChoice) === "boolean" && userChoice)
+						{
+							anyOptionChanged = true;
+							gCfgInfo.cfgOptions.startupSubBoardCode = "";
+							menuItems[optionMenuSelection] = formatCfgMenuText(itemTextMaxLen, optionStrs[optionMenuSelection], gCfgInfo.cfgOptions[optName]);
+						}
+					}
+				}
+			}
+			else if (optName == "subBoardCodes")
+			{
+				var subCodes = displayMsgGrpsAndToggleSubBoards(gCfgInfo.cfgOptions.subBoardCodes);
+				if (typeof(subCodes) === "string" && subCodes != gCfgInfo.cfgOptions.subBoardCodes)
+				{
+					anyOptionChanged = true;
+					gCfgInfo.cfgOptions.subBoardCodes = subCodes;
+					menuItems[optionMenuSelection] = formatCfgMenuText(itemTextMaxLen, optionStrs[optionMenuSelection], subCodes != "" ? gAllowedSubsCfgMenuText : "")
+				}
+			}
+			else if (itemType === "boolean")
+			{
+				anyOptionChanged = true;
+				gCfgInfo.cfgOptions[optName] = !gCfgInfo.cfgOptions[optName];
+				menuItems[optionMenuSelection] = formatCfgMenuText(itemTextMaxLen, optionStrs[optionMenuSelection], gCfgInfo.cfgOptions[optName]);
+			}
+		}
+	}
+
+	return anyOptionChanged;
+}
+
+// Formats text for a behavior option
+//
+// Parameters:
+//  pItemTextMaxLen: The maximum length for menu item text
+//  pItemText: The text of the item to be displayed on the menu
+//  pVal: The value of the option
+//
+// Return value: The formatted text for the menu item, with a Yes/No value indicating whether it's enabled
+function formatCfgMenuText(pItemTextMaxLen, pItemText, pVal)
+{
+	if (formatCfgMenuText.formatStr == undefined)
+		formatCfgMenuText.formatStr = "%-" + pItemTextMaxLen + "s %s";
+	// Determine what should be displayed for the value
+	var valType = typeof(pVal);
+	var value = "";
+	if (valType === "boolean")
+		value = pVal ? "Yes" : "No";
+	else
+	{
+		if (typeof(pVal) !== "undefined")
+			value = pVal.toString();
+	}
+	return format(formatCfgMenuText.formatStr, pItemText.substr(0, pItemTextMaxLen), value);
+}
+
+// Starting with the message groups, allows the user to choose a single sub-board
+//
+// Return value: The internal code of the chosen sub-board, or undefined if none was chosen
+function displayMsgGrpsAndChooseSubBoard()
+{
+	var subCode = undefined;
+
+	// Generate a list of message groups and the number of sub-boards they each have
+	var msgGroups = [];
+	var grpNameLen = 35;
+	var numSubBoardsLen = 10;
+	var formatStr = "%-" + grpNameLen + "s %" + numSubBoardsLen + "d";
+	for (var grpIdx = 0; grpIdx < msg_area.grp_list.length; ++grpIdx)
+		msgGroups.push(format(formatStr, msg_area.grp_list[grpIdx].description, msg_area.grp_list[grpIdx].sub_list.length));
+
+	// Create a CTX to specify the current selected item index
+	if (displayMsgGrpsAndChooseSubBoard.ctx == undefined)
+		displayMsgGrpsAndChooseSubBoard.ctx = uifc.list.CTX();
+	// Selection
+	var winMode = WIN_ORG|WIN_MID|WIN_ACT|WIN_ESC;
+	var menuTitle = format("%-" + grpNameLen + "s %" + numSubBoardsLen + "s", "Message Groups", "Sub-boards");
+	var continueOn = true;
+	while (continueOn && !js.terminated)
+	{
+		//uifc.help_text = displayMsgGrpsAndChooseSubBoard.mainScreenHelp;
+		var selectedMsgGrpIdx = uifc.list(winMode, menuTitle, msgGroups, displayMsgGrpsAndChooseSubBoard.ctx);
+		if (selectedMsgGrpIdx == -1) // ESC
+		{
+			subCode = undefined;
+			continueOn = false;
+		}
+		else
+		{
+			var chosenSubCode = chooseSubBoardFromMsgGroup(selectedMsgGrpIdx);
+			if (typeof(chosenSubCode) === "string" && chosenSubCode != "")
+			{
+				// chooseSubBoardFromMsgGroup() will confirm with the user
+				subCode = chosenSubCode;
+				continueOn = false;
+				/*
+				var userChoice = promptYesNo(msg_area.grp_list[grpIdx].name + ": Are you sure?", true, WIN_ORG|WIN_MID|WIN_ACT|WIN_ESC);
+				if (typeof(userChoice) === "boolean" && userChoice)
+				{
+					subCode = chosenSubCode;
+					continueOn = false;
+				}
+				*/
+			}
+		}
+	}
+
+	return subCode;
+}
+// Lets the user choose a sub-board of a message group
+//
+// Parameters:
+//  pGrpIdx: The index of the message group
+//
+// Return value: The internal code of the chosen sub-board (string), or undefined if none was chosen
+function chooseSubBoardFromMsgGroup(pGrpIdx)
+{
+	var subCode = undefined;
+
+	// Generate a list of the sub-boards in the message group and the number of sub-boards they each have
+	var maxSubDescLen = 50;
+	var subBoards = [];
+	for (var subIdx = 0; subIdx < msg_area.grp_list[pGrpIdx].sub_list.length; ++subIdx)
+		subBoards.push(msg_area.grp_list[pGrpIdx].sub_list[subIdx].description.substr(0, maxSubDescLen));
+
+	// Create a CTX to specify the current selected item index
+	if (chooseSubBoardFromMsgGroup.ctx == undefined)
+		chooseSubBoardFromMsgGroup.ctx = uifc.list.CTX();
+	// Selection
+	var winMode = WIN_ORG|WIN_MID|WIN_ACT|WIN_ESC;
+	var menuTitle = format("%s Sub-boards (%d)", msg_area.grp_list[pGrpIdx].name, msg_area.grp_list[pGrpIdx].sub_list.length);
+	var continueOn = true;
+	while (continueOn && !js.terminated)
+	{
+		//uifc.help_text = chooseSubBoardFromMsgGroup.mainScreenHelp;
+		var selectedMsgGrpIdx = uifc.list(winMode, menuTitle, subBoards, chooseSubBoardFromMsgGroup.ctx);
+		if (selectedMsgGrpIdx == -1) // ESC
+		{
+			subCode = undefined;
+			continueOn = false;
+		}
+		else
+		{
+			var chosenSubCode = msg_area.grp_list[pGrpIdx].sub_list[selectedMsgGrpIdx].code;
+			var userChoice = promptYesNo(msg_area.grp_list[pGrpIdx].sub_list[selectedMsgGrpIdx].name + ": Are you sure?", true, WIN_ORG|WIN_MID|WIN_ACT|WIN_ESC);
+			if (typeof(userChoice) === "boolean" && userChoice)
+			{
+				subCode = chosenSubCode;
+				continueOn = false;
+			}
+		}
+	}
+
+	return subCode;
+}
+
+// Starting with the message groups, allows the user to toggle multiple sub-boards on/off
+//
+// Parameters:
+//  pCurrentSubCodeList: String - A comma-separated list of all current enabled sub-board codes
+//
+// Return value: A comma-separated list of internal codes of all enabled sub-boards, or an empty string if there are none
+function displayMsgGrpsAndToggleSubBoards(pCurrentSubCodeList)
+{
+	var subCodes = typeof(pCurrentSubCodeList) === "string" ? pCurrentSubCodeList : "";
+
+	// Generate a list of message groups and the number of sub-boards they each have
+	var msgGroups = [];
+	var grpNameLen = 35;
+	var numSubBoardsLen = 10;
+	var formatStr = "%-" + grpNameLen + "s %" + numSubBoardsLen + "d";
+	for (var grpIdx = 0; grpIdx < msg_area.grp_list.length; ++grpIdx)
+		msgGroups.push(format(formatStr, msg_area.grp_list[grpIdx].description, msg_area.grp_list[grpIdx].sub_list.length));
+
+	// Create a CTX to specify the current selected item index
+	if (displayMsgGrpsAndChooseSubBoard.ctx == undefined)
+		displayMsgGrpsAndChooseSubBoard.ctx = uifc.list.CTX();
+	// Selection
+	var winMode = WIN_ORG|WIN_MID|WIN_ACT|WIN_ESC;
+	var menuTitle = format("%-" + grpNameLen + "s %" + numSubBoardsLen + "s", "Message Groups", "Sub-boards");
+	var continueOn = true;
+	while (continueOn && !js.terminated)
+	{
+		//uifc.help_text = displayMsgGrpsAndChooseSubBoard.mainScreenHelp;
+		var selectedMsgGrpIdx = uifc.list(winMode, menuTitle, msgGroups, displayMsgGrpsAndChooseSubBoard.ctx);
+		if (selectedMsgGrpIdx == -1) // ESC
+			continueOn = false;
+		else
+		{
+			var toggledSubCodes = chooseMultipleSubBoardsFromMsgGroup(selectedMsgGrpIdx, subCodes);
+			if (typeof(toggledSubCodes) === "string")
+				subCodes = toggledSubCodes;
+		}
+	}
+
+	return subCodes;
+}
+// Lets the user choose multiple sub-boards from a message group
+//
+// Parameters:
+//  pGrpIdx: The index of the message group
+//  pCurrentSubCodeList: String - A comma-separated list of all current enabled sub-board codes
+//
+// Return value: A comma-separated list of internal codes of all enabled sub-boards, or an empty string if there are none
+function chooseMultipleSubBoardsFromMsgGroup(pGrpIdx, pCurrentSubCodeList)
+{
+	// Split pCurrentSubCodeList on commas to make an array of sub-board codes
+	var currentEnabledSubCodes = {};
+	if (typeof(pCurrentSubCodeList) === "string")
+	{
+		var enabledSubCodes = pCurrentSubCodeList.split(",");
+		for (var i = 0; i < enabledSubCodes.length; ++i)
+		{
+			var subCode = enabledSubCodes[i];
+			currentEnabledSubCodes[subCode] = true;
+		}
+	}
+
+	// Generate a list of the sub-boards in the message group and the number of sub-boards they each have
+	var subDescLen = 30;
+	var enabledLen = 7;
+	var formatStr = "%-" + subDescLen + "s %" + enabledLen + "s";
+	var subBoards = [];
+	for (var subIdx = 0; subIdx < msg_area.grp_list[pGrpIdx].sub_list.length; ++subIdx)
+	{
+		var subIsEnabled = currentEnabledSubCodes.hasOwnProperty(msg_area.grp_list[pGrpIdx].sub_list[subIdx].code);
+		subBoards.push(format(formatStr, msg_area.grp_list[pGrpIdx].sub_list[subIdx].description.substr(0, subDescLen), subIsEnabled ? "Yes" : "No"));
+	}
+
+	// Create a CTX to specify the current selected item index
+	if (chooseSubBoardFromMsgGroup.ctx == undefined)
+		chooseSubBoardFromMsgGroup.ctx = uifc.list.CTX();
+	// Selection
+	var winMode = WIN_ORG|WIN_MID|WIN_ACT|WIN_ESC;
+	var subBoardInfoStr = format("%s Sub-boards (%d)", msg_area.grp_list[pGrpIdx].name, msg_area.grp_list[pGrpIdx].sub_list.length);
+	var menuTitle = format(formatStr, subBoardInfoStr.substr(0, subDescLen), "Enabled");
+	var continueOn = true;
+	while (continueOn && !js.terminated)
+	{
+		//uifc.help_text = chooseSubBoardFromMsgGroup.mainScreenHelp;
+		var selectedSubIdx = uifc.list(winMode, menuTitle, subBoards, chooseSubBoardFromMsgGroup.ctx);
+		if (selectedSubIdx == -1) // ESC
+			continueOn = false;
+		else
+		{
+			// Toggle the sub-board
+			if (currentEnabledSubCodes.hasOwnProperty(msg_area.grp_list[pGrpIdx].sub_list[selectedSubIdx].code))
+			{
+				delete currentEnabledSubCodes[msg_area.grp_list[pGrpIdx].sub_list[selectedSubIdx].code];
+				subBoards[selectedSubIdx] = format(formatStr, msg_area.grp_list[pGrpIdx].sub_list[selectedSubIdx].description, "No");
+			}
+			else
+			{
+				currentEnabledSubCodes[msg_area.grp_list[pGrpIdx].sub_list[selectedSubIdx].code] = true;
+				subBoards[selectedSubIdx] = format(formatStr, msg_area.grp_list[pGrpIdx].sub_list[selectedSubIdx].description, "Yes");
+			}
+		}
+	}
+
+	// Convert the object of all enabled sub-boards back to a comma-separated list (string) and return it
+	var subCodes = "";
+	for (var subCode in currentEnabledSubCodes)
+		subCodes += subCode + ",";
+	subCodes = subCodes.replace(/,$/, ""); // Remove trailing comma
+	subCodes = subCodes.replace(/^,/, ""); // Remove initial comma if there is one
+	return subCodes;
+}
+
+// Prompts the user Yes/No for a boolean response
+//
+// Parameters:
+//  pQuestion: The question/prompt for the user
+//  pInitialVal: Boolean - Whether the initial selection in the menu should
+//               be Yes (true) or No (false)
+//  pWinMode: Optional window mode bits.  If not specified, WIN_MID will be used.
+//
+//  Return value: Boolean true (yes), false (no), or null if the user aborted
+function promptYesNo(pQuestion, pInitialVal, pWinMode)
+{
+	var chosenVal = null;
+	var winMode = typeof(pWinMode) === "number" ? pWinMode : WIN_MID;
+	// Create a CTX to specify the current selected item index
+	var ctx = uifc.list.CTX();
+	ctx.cur = typeof(pInitialVal) === "boolean" && pInitialVal ? 0 : 1;
+	switch (uifc.list(winMode, pQuestion, ["Yes", "No"], ctx))
+	{
+		case -1: // User quit/aborted - Leave chosenVal as null
+			break;
+		case 0: // User chose Yes
+			chosenVal = true;
+			break;
+		case 1: // User chose No
+			chosenVal = false;
+			break;
+		default:
+			break;
+	}
+	return chosenVal;
+}
+
+
+// Reads the configuration file
+function readCfgFile()
+{
+	var retObj = {
+		cfgFilename: "",
+		successfullyOpened: false,
+		cfgOptions: {
+			showAvatars: true,
+			useAllAvailableSubBoards: true,
+			startupSubBoardCode: "",
+			subBoardCodes: ""
+		}
+	};
+
+	// Determine the location of the configuration file.  First look for it
+	// in the sbbs/mods directory, then sbbs/ctrl, then in the same directory
+	// as this script.
+	retObj.cfgFilename = file_cfgname(system.mods_dir, gCfgFilename);
+	if (!file_exists(retObj.cfgFilename))
+		retObj.cfgFilename = file_cfgname(system.ctrl_dir, gCfgFilename);
+	if (!file_exists(retObj.cfgFilename))
+		retObj.cfgFilename = file_cfgname(js.exec_dir, gCfgFilename);
+	var cfgFile = new File(retObj.cfgFilename);
+	if (cfgFile.open("r"))
+	{
+		var settingsObj = cfgFile.iniGetObject();
+		cfgFile.close();
+		retObj.successfullyOpened = true;
+
+		// Copy the values into retObj.cfgOptions
+		if (settingsObj.hasOwnProperty("showAvatars") && typeof(settingsObj.showAvatars) === "boolean")
+			retObj.cfgOptions.showAvatars = settingsObj.showAvatars;
+		if (settingsObj.hasOwnProperty("useAllAvailableSubBoards") && typeof(settingsObj.useAllAvailableSubBoards) === "boolean")
+			retObj.cfgOptions.useAllAvailableSubBoards = settingsObj.useAllAvailableSubBoards;
+		if (settingsObj.hasOwnProperty("startupSubBoardCode") && typeof(settingsObj.startupSubBoardCode) === "string")
+			retObj.cfgOptions.startupSubBoardCode = settingsObj.startupSubBoardCode;
+		if (settingsObj.hasOwnProperty("subBoardCodes") && typeof(settingsObj.subBoardCodes) === "string")
+			retObj.cfgOptions.subBoardCodes = settingsObj.subBoardCodes;
+	}
+	return retObj;
+}
+
+// Saves the configuration file using the settings in gCfgInfo
+//
+// Return value: An object with the following properties:
+//               saveSucceeded: Boolean - Whether or not the save fully succeeded
+//               savedToModsDir: Boolean - Whether or not the .cfg file was saved to the mods directory
+function saveCfgFile()
+{
+	var retObj = {
+		saveSucceeded: false,
+		savedToModsDir: false
+	};
+
+	// If the configuration file was loaded from the standard location in
+	// the Git repository (xtrn/slyvote), and the option to always save
+	// in the original directory is not set, then the configuration file
+	// should be copied to sbbs/mods to avoid custom settings being overwritten
+	// with an update.
+	var defaultDirRE = new RegExp("xtrn[\\\\/]slyvote[\\\\/]" + gCfgFilename + "$");
+	var cfgFilename = gCfgInfo.cfgFilename;
+	if (defaultDirRE.test(cfgFilename) && !gAlwaysSaveCfgInOriginalDir)
+	{
+		cfgFilename = system.mods_dir + gCfgFilename;
+		if (!file_copy(gCfgInfo.cfgFilename, cfgFilename))
+			return false;
+		retObj.savedToModsDir = true;
+	}
+
+	var cfgFile = new File(cfgFilename);
+	if (cfgFile.open("r+")) // Reading and writing (file must exist)
+	{
+		for (var settingName in gCfgInfo.cfgOptions)
+			cfgFile.iniSetValue(null, settingName, gCfgInfo.cfgOptions[settingName]);
+		cfgFile.close();
+		retObj.saveSucceeded = true;
+	}
+
+	return retObj;
+}
+
+
+
+///////////////////////////////////////////////////
+// Help text functions
+
+
+// Returns a dictionary of help text, indexed by the option name from the configuration file
+function getOptionHelpText()
+{
+	var optionHelpText = {};
+	optionHelpText["showAvatars"] = "Show avatars in poll messages: Whether or not to show user avatar ANSIs ";
+	optionHelpText["showAvatars"] += "in the header when reading/viewing polls";
+
+	optionHelpText["useAllAvailableSubBoards"] = "Use all available sub-boards: Whether or not to let the user access all sub-boards ";
+	optionHelpText["useAllAvailableSubBoards"] += "from SlyVote. For instance, you might have only certain sub-boards that allow voting, ";
+	optionHelpText["useAllAvailableSubBoards"] += "or you might set up only one designated sub-board for polls, if desired";
+
+	optionHelpText["startupSubBoardCode"] = "Startup sub-board: The initial sub-board to use on startup. This is saved in the ";
+	optionHelpText["startupSubBoardCode"] += "configuration file as the internal sub-board code.";
+
+	optionHelpText["subBoardCodes"] = "List of sub-boards to allow: If not using all sub-boards, this is a set of sub-boards ";
+	optionHelpText["subBoardCodes"] += "to give users access to in SlyVote. For instance, you might have only certain sub-boards ";
+	optionHelpText["subBoardCodes"] += "that allow voting, or you might set up only one designated sub-board for polls, if desired. ";
+	optionHelpText["subBoardCodes"] += "This is saved in the configuration file as a comma-separated list of internal sub-board ";
+	optionHelpText["subBoardCodes"] += "codes";
+
+	// Word-wrap the help text items
+	for (var prop in optionHelpText)
+		optionHelpText[prop] = word_wrap(optionHelpText[prop], gHelpWrapWidth);
+
+	return optionHelpText;
+}
+
+// Returns help text for the main configuration screen (behavior settings)
+//
+// Parameters:
+//  pOptionHelpText: An object of help text for each option, indexed by the option name from the configuration file
+//  pCfgOptProps: An array specifying the properties to include in the help text and their order
+//
+// Return value: Help text for the main configuration screen (behavior settings)
+function getMainHelp(pOptionHelpText, pCfgOptProps)
+{
+	var helpText = "This screen allows you to configure the options for SlyVote.\r\n\r\n";
+	for (var i = 0; i < pCfgOptProps.length; ++i)
+	{
+		var optName = pCfgOptProps[i];
+		//helpText += pOptionHelpText[optName] + "\r\n\r\n";
+		helpText += pOptionHelpText[optName] + "\r\n";
+	}
+	return word_wrap(helpText, gHelpWrapWidth);
+}
\ No newline at end of file
diff --git a/xtrn/slyvote/slyvote.js b/xtrn/slyvote/slyvote.js
index a82d49953e328f816c82ce1914f8a88787c824bd..90672212ab6bbf2f972cf4cf60348124b6318ddd 100644
--- a/xtrn/slyvote/slyvote.js
+++ b/xtrn/slyvote/slyvote.js
@@ -186,6 +186,12 @@
  *                              different sub-board. Refactored ReadConfigFile().
  * 2023-09-20 Eric Oulashin     Version 1.14
  *                              Fixed poll voting for single-answer polls
+ * 2023-12-10 Eric Oulashin     Version 1.15
+ *                              Code refactor; no difference in functionality.
+ *                              Now uses js.exec_dir instead of the hack to find out
+ *                              what directory the script is running in.
+ *                              Now uses user.is_sysop rather than storing a global
+ *                              variable with an ARS check for "SYSOP".
  */
 
 // TODO: Have a messsage group selection so that it doesn't have to display all
@@ -257,16 +263,8 @@ else
 var gAvatar = load({}, "avatar_lib.js");
 
 // Version information
-var SLYVOTE_VERSION = "1.14";
-var SLYVOTE_DATE = "2023-09-20";
-
-// Determine the script's startup directory.
-// This code is a trick that was created by Deuce, suggested by Rob Swindell
-// as a way to detect which directory the script was executed in.  I've
-// shortened the code a little.
-var gStartupPath = '.';
-try { throw dig.dist(dist); } catch(e) { gStartupPath = e.fileName; }
-gStartupPath = backslash(gStartupPath.replace(/[\/\\][^\/\\]*$/,''));
+var SLYVOTE_VERSION = "1.15";
+var SLYVOTE_DATE = "2023-12-09";
 
 // Characters for display
 // Box-drawing/border characters: Single-line
@@ -361,8 +359,6 @@ var ERROR_PAUSE_WAIT_MS = 1500;
 var gBottomBorderRow = 23;
 var gMessageRow = 3;
 
-var gUserIsSysop = user.compare_ars("SYSOP");
-
 // Keyboard key codes for displaying on the screen
 var UP_ARROW = ascii(24);
 var DOWN_ARROW = ascii(25);
@@ -388,10 +384,10 @@ var gReaderKeys = {
 	goToLast: "L",
 	vote: "V",
 	close: "C",
+	validateMsg: "A", // Only for the sysop
 	quit: "Q"
 };
-if (gUserIsSysop)
-	gReaderKeys.validateMsg = "A";
+
 
 var gSlyVoteCfg = ReadConfigFile();
 if (gSlyVoteCfg.cfgReadError.length > 0)
@@ -1223,7 +1219,7 @@ function ReadConfigFile()
 	if (!file_exists(cfgFilename))
 		cfgFilename = file_cfgname(system.ctrl_dir, filename);
 	if (!file_exists(cfgFilename))
-		cfgFilename = file_cfgname(gStartupPath, filename);
+		cfgFilename = file_cfgname(js.exec_dir, filename);
 	var cfgFile = new File(cfgFilename);
 	if (cfgFile.open("r"))
 	{
@@ -1676,7 +1672,7 @@ function GetMsgBody(pMsgbase, pMsgHdr, pSubBoardCode, pUser)
 	// sysop now know to validate it.
 	if (pSubBoardCode != "mail")
 	{
-		if (gUserIsSysop && msg_area.sub[pSubBoardCode].is_moderated && ((pMsgHdr.attr & MSG_VALIDATED) == 0))
+		if (user.is_sysop && msg_area.sub[pSubBoardCode].is_moderated && ((pMsgHdr.attr & MSG_VALIDATED) == 0))
 		{
 			var validateNotice = "\x01n\x01h\x01yThis is an unvalidated message in a moderated area.  Press "
 							   + gReaderKeys.validateMsg + " to validate it.\r\n\x01g";
@@ -2610,7 +2606,7 @@ function ViewVoteResults(pSubBoardCode)
 			}
 			else if (scrollRetObj.lastKeypress == gReaderKeys.validateMsg)
 			{
-				if (gUserIsSysop && msg_area.sub[pSubBoardCode].is_moderated)
+				if (user.is_sysop && msg_area.sub[pSubBoardCode].is_moderated)
 				{
 					var validated = ValidateMsg(msgbase, pSubBoardCode, pollMsgHdrs[currentMsgIdx].number);
 					console.gotoxy(1, console.screen_rows-2);
@@ -3583,7 +3579,7 @@ function IsReadableMsgHdr(pMsgHdr, pSubBoardCode)
 		return false;
 
 	// Let the sysop see unvalidated messages and private messages but not other users.
-	if (!gUserIsSysop)
+	if (!user.is_sysop)
 	{
 		if ((msg_area.sub[pSubBoardCode].is_moderated && ((pMsgHdr.attr & MSG_VALIDATED) == 0)) ||
 		    (((pMsgHdr.attr & MSG_PRIVATE) == MSG_PRIVATE) && !userHandleAliasNameMatch(pMsgHdr.to)))
@@ -3711,7 +3707,7 @@ function DrawVoteColumns(pTopRow, pBottomRow, pColumnX1, pColumnX2)
 // REturen value: Boolean - Whether deleting a poll is allowed
 function PollDeleteAllowed(pMsgbase, pSubBoardCode)
 {
-	var canDelete = gUserIsSysop || (pSubBoardCode == "mail");
+	var canDelete = user.is_sysop || (pSubBoardCode == "mail");
 	if ((pMsgbase != null) && pMsgbase.is_open && (pMsgbase.cfg != null))
 		canDelete = canDelete || ((pMsgbase.cfg.settings & SUB_DEL) == SUB_DEL);
 	return canDelete;
@@ -3743,7 +3739,7 @@ function DisplayViewingResultsHelpScr(pMsgbase, pSubBoardCode)
 	                    "\x01h\x01cEND              \x01g: \x01n\x01cGo to the bottom of the text",
 						"\x01h\x01cF                \x01g: \x01n\x01cGo to the first poll",
 						"\x01h\x01cL                \x01g: \x01n\x01cGo to the last poll"];
-	if (gUserIsSysop)
+	if (user.is_sysop)
 		keyHelpLines.push("\x01h\x01cDEL              \x01g: \x01n\x01cDelete the current poll");
 	else if (PollDeleteAllowed(pMsgbase, pSubBoardCode))
 		keyHelpLines.push("\x01h\x01cDEL              \x01g: \x01n\x01cDelete the current poll (if it's yours)");