Greasy Fork is available in English.
Unified Nitro Type mod menu shell at /settings/mods with route takeover, mod tabs, and race-options-inspired settings layout.
// ==UserScript==
// @name Nitro Type Mod Menu
// @namespace https://greasyfork.org/users/1443935
// @version 1.0.0
// @description Unified Nitro Type mod menu shell at /settings/mods with route takeover, mod tabs, and race-options-inspired settings layout.
// @author Captain.Loveridge
// @match https://www.nitrotype.com/*
// @grant none
// @run-at document-start
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const MOD_MENU_PATH = '/settings/mods';
const DROPDOWN_ITEM_CLASS = 'ntmods-dropdown-item';
const MANIFEST_PREFIX = 'ntcfg:manifest:';
const UI_STATE_KEY = 'ntmods:ui-state';
const PREVIEW_VALUE_PREFIX = 'ntmods:preview:';
const PREVIEW_FALLBACK_ENABLED = true;
let renderInProgress = false;
let handleScheduled = false;
const toggleField = (key, label, defaultValue, help) => ({
type: 'toggle',
key,
label,
default: defaultValue,
help
});
const numberField = (key, label, defaultValue, help, min = null, max = null, step = '1') => ({
type: 'number',
key,
label,
default: defaultValue,
help,
min,
max,
step
});
const selectField = (key, label, defaultValue, options, help) => ({
type: 'select',
key,
label,
default: defaultValue,
options,
help
});
const textField = (key, label, defaultValue, help, placeholder = '') => ({
type: 'text',
key,
label,
default: defaultValue,
help,
placeholder
});
const colorField = (key, label, defaultValue, help) => ({
type: 'color',
key,
label,
default: defaultValue,
help
});
const noteField = (message, tone = 'info') => ({
type: 'note',
message,
tone
});
const actionField = (key, label, style = 'primary', help = '') => ({
type: 'action',
key,
label,
style,
help
});
const MODULE_LIBRARY = [
{
id: 'race-options',
label: 'Race Options',
shortLabel: 'RO',
accent: '#1c99f4',
description: 'Race overlays, pacing tools, and theme controls for the race screen.',
installUrl: 'https://greasyfork.org/en/scripts/567849-nitro-type-race-options',
previewSections: [
{
id: 'general',
title: 'General',
subtitle: 'Visual cleanup, overlays, and race helpers.',
items: [
toggleField('hide_track', 'Hide Track', false, 'Removes the large race track art to keep the page cleaner.'),
toggleField('hide_notifications', 'Hide Notifications', true, 'Suppresses popups while a race is active.'),
toggleField('enable_mini_map', 'Enable Mini Map', false, 'Shows a compact lane map near the race text.'),
selectField('mini_map_position', 'Mini Map Position', 'bottom', [
{ value: 'bottom', label: 'Bottom' },
{ value: 'top', label: 'Top' }
], 'Choose where the mini map sits relative to the race UI.'),
{ ...toggleField('enable_racer_badges_in_race', 'Enable Racer Badges (In-Race)', true, 'Lets racer badges inject race nameplate icons.'), storageKey: 'ntcfg:racer-badges:ENABLE_RACE_BADGES', emitModule: 'racer-badges', emitKey: 'ENABLE_RACE_BADGES' },
noteField('This section is a visual shell right now. The live bridge will replace these preview keys with shared mod settings.', 'info')
]
},
{
id: 'pacing',
title: 'Pacing',
subtitle: 'Alt WPM and reload flow.',
items: [
toggleField('enable_alt_wpm_counter', 'Enable Alt. WPM / Countdown', true, 'Displays the draggable pace helper during the race.'),
numberField('target_wpm', 'Target WPM', 79.5, 'Target pace used by the helper.', 1, 300, '0.1'),
numberField('indicate_wpm_within', 'Yellow Threshold', 2, 'Highlight the helper when pace is within this range.', 0, 30, '0.1'),
toggleField('reload_on_stats', 'Enable Auto Reload', true, 'Refresh after the results screen updates.'),
numberField('greedy_stats_reload_int', 'Fast Reload Interval (ms)', 50, 'Low values reload sooner but can be heavier.', 25, 500, '5'),
actionField('preview_reload_action', 'Preview Save and Reload', 'primary', 'A placeholder action so the footer/button styling can be reviewed.')
]
},
{
id: 'theme',
title: 'Theme',
subtitle: 'Text colors, backgrounds, and typing feel.',
items: [
toggleField('theme_enable_dark_mode', 'Enable Dark Mode', false, 'Uses the darker theme palette on the race page.'),
colorField('theme_color_background', 'Background Color', '#0A121E', 'Main background color used for text theming.'),
colorField('theme_color_background_active', 'Active Word Background', '#1C99F4', 'Background color for the active word.'),
colorField('theme_color_foreground', 'Foreground Color', '#E7EEF8', 'Main text color for upcoming characters.'),
selectField('theme_font_family_preset', 'Font Family', 'roboto_mono', [
{ value: 'roboto_mono', label: 'Roboto Mono' },
{ value: 'montserrat', label: 'Montserrat' },
{ value: 'space_mono', label: 'Space Mono' }
], 'Preview typography for the race text.'),
toggleField('theme_enable_rainbow_typed_text', 'Rainbow Typed Text', false, 'Applies animated color cycling to typed text.')
]
}
]
},
{
id: 'racer-badges',
label: 'Racer Badges',
shortLabel: 'RB',
accent: '#ffd166',
description: 'Top racer and top team badge placement, in-race badge rules, and badge presentation.',
installUrl: 'https://greasyfork.org/en/scripts/555617-nitro-type-top-racer-team-badges',
previewSections: [
{
id: 'general',
title: 'General',
subtitle: 'Global badge and banner visibility controls.',
items: [
toggleField('SHOW_RACER_BADGES', 'Show Racer Badges', true, 'Show small inline top-racer ranking icons next to names in lists.'),
toggleField('SHOW_RACER_BANNERS', 'Show Racer Banners', true, 'Show large top-racer ranking images on profile and garage pages.'),
toggleField('SHOW_TEAM_BADGES', 'Show Team Badges', true, 'Show small inline top-team ranking icons next to names in lists.'),
toggleField('SHOW_TEAM_BANNERS', 'Show Team Banners', true, 'Show large top-team ranking images on team, profile, and garage pages.'),
noteField('Install the Racer Badges script for these settings to take effect.', 'info')
]
},
{
id: 'pages',
title: 'Pages',
subtitle: 'Per-page badge visibility.',
items: [
toggleField('ENABLE_RACE_BADGES', 'Enable Race Page', true, 'Show badge overlays inside the race view on racer nameplates.'),
toggleField('ENABLE_PROFILE_BADGES', 'Enable Profile Pages', true, 'Show badges and banners on racer profile pages.'),
toggleField('ENABLE_TEAM_PAGE_BADGES', 'Enable Team Pages', true, 'Show badges and banners on team pages.'),
toggleField('ENABLE_GARAGE_BADGES', 'Enable Garage Pages', true, 'Show badges and banners on garage pages.'),
toggleField('ENABLE_FRIENDS_BADGES', 'Enable Friends Page', true, 'Show badges on the friends page.'),
toggleField('ENABLE_LEAGUES_BADGES', 'Enable Leagues Page', true, 'Show badges inside league tables.'),
toggleField('ENABLE_HEADER_BADGE', 'Enable Header Badge', true, 'Display your own badge in the site header.'),
noteField('Install the Racer Badges script for these settings to take effect.', 'info')
]
},
{
id: 'advanced',
title: 'Advanced',
subtitle: 'Debug and diagnostic controls.',
items: [
toggleField('DEBUG_LOGGING', 'Debug Logging', false, 'Enable verbose console logging for troubleshooting.'),
noteField('Install the Racer Badges script for these settings to take effect.', 'info')
]
}
]
},
{
id: 'leaderboards',
label: 'Leaderboards',
shortLabel: 'LB',
accent: '#6c8cff',
description: 'StarTrack leaderboard routing, cached timeframes, and background refresh behavior.',
installUrl: 'https://greasyfork.org/en/scripts/555066-nitro-type-startrack-leaderboard-integration',
previewSections: [
{
id: 'views',
title: 'Views',
subtitle: 'Top-level presentation and default landing behavior.',
items: [
selectField('default_view', 'Default View', 'individual', [
{ value: 'individual', label: 'Top Racers' },
{ value: 'team', label: 'Top Teams' }
], 'Controls which leaderboard view opens first.'),
selectField('default_timeframe', 'Default Timeframe', 'season', [
{ value: 'season', label: 'Season' },
{ value: '24hr', label: 'Last 24 Hours' },
{ value: '7day', label: 'Last 7 Days' }
], 'Preview the default timeframe selection.'),
toggleField('highlight_position_change', 'Highlight Position Change', true, 'Show movement arrows beside leaderboard entries.'),
toggleField('manual_refresh_button', 'Show Manual Refresh Button', true, 'Keep the refresh control visible in the page header.')
]
},
{
id: 'sync',
title: 'Sync',
subtitle: 'Refresh cadence and cache freshness.',
items: [
toggleField('daily_background_sync', 'Daily Background Sync', true, 'Warm cache data for daily-style ranges.'),
toggleField('hourly_background_sync', 'Hourly Background Sync', true, 'Refresh fast-moving windows in the background.'),
numberField('cache_duration_minutes', 'Cache Duration (minutes)', 15, 'General cache freshness for rolling windows.', 1, 120, '1'),
actionField('preview_resync', 'Preview Sync All Tabs', 'primary', 'Placeholder for a future cross-tab refresh action.')
]
},
{
id: 'presentation',
title: 'Presentation',
subtitle: 'Spacing, motion, and accent treatment.',
items: [
colorField('accent_color', 'Accent Color', '#1C99F4', 'Used by route highlights and selected pills.'),
toggleField('anti_flicker_mode', 'Anti-Flicker Route Mode', true, 'Hide the default page shell until the custom page is ready.'),
toggleField('show_route_tab', 'Show Leaderboards Nav Tab', true, 'Controls the custom nav insertion.'),
noteField('The mods page is borrowing this script\'s route takeover pattern for its own shell.', 'info')
]
}
]
},
{
id: 'bot-flag',
label: 'Bot Flag',
shortLabel: 'BF',
accent: '#ff9f1c',
description: 'StarTrack + NTL flag display controls, cache behavior, and status indicators.',
installUrl: 'https://greasyfork.org/en/scripts/529360-nitro-type-flag-check-startrack-ntl-legacy',
previewSections: [
{
id: 'display',
title: 'Display',
subtitle: 'Visual placement and on-page behavior.',
items: [
toggleField('show_team_page_flags', 'Show Team Page Flags', true, 'Render badges on team rosters and member lists.'),
toggleField('show_friends_page_flags', 'Show Friends Page Flags', true, 'Keep friend page annotations enabled.'),
toggleField('show_league_page_flags', 'Show League Page Flags', true, 'Display status icons inside league tables.'),
selectField('tooltip_style', 'Tooltip Style', 'rich', [
{ value: 'rich', label: 'Rich Tooltip' },
{ value: 'compact', label: 'Compact Tooltip' }
], 'Preview how detailed each hover card should be.')
]
},
{
id: 'sources',
title: 'Sources',
subtitle: 'Data source preferences and fetch policy.',
items: [
toggleField('use_startrack', 'Use StarTrack Data', true, 'Primary flag source for status checks.'),
toggleField('use_ntl_legacy', 'Use NTL Legacy Data', true, 'Fallback source for additional history.'),
numberField('network_concurrency_limit', 'Network Concurrency Limit', 6, 'Soft cap for simultaneous user lookups.', 1, 20, '1'),
toggleField('debug_logging', 'Debug Logging', false, 'Enable verbose logging for troubleshooting.')
]
},
{
id: 'cache',
title: 'Cache',
subtitle: 'How long flag results should be kept around.',
items: [
numberField('startrack_cache_days', 'StarTrack Cache (days)', 7, 'How long StarTrack lookups should be considered fresh.', 1, 30, '1'),
toggleField('cross_tab_sync', 'Cross-Tab Sync', true, 'Broadcast fresh data to other open Nitro Type tabs.'),
actionField('clear_flag_cache', 'Preview Clear Cache', 'danger', 'Placeholder action button for the maintenance area.'),
noteField('Cache maintenance actions are visual-only in this shell pass.', 'warning')
]
}
]
},
{
id: 'music-player',
label: 'Music Player',
shortLabel: 'MP',
accent: '#1db954',
description: 'Universal music playback shell for Spotify, YouTube, and Apple Music sources.',
installUrl: 'https://greasyfork.org/en/scripts/567896-nitro-type-universal-music-player',
previewSections: [
{
id: 'source',
title: 'Source',
subtitle: 'Where the queue comes from.',
items: [
textField('source_url', 'Source URL', '', 'Spotify, YouTube, or Apple Music playlist/album/track URL.', 'Paste playlist, album, or track URL'),
selectField('default_platform', 'Default Platform', 'spotify', [
{ value: 'spotify', label: 'Spotify' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'apple-music', label: 'Apple Music' }
], 'Controls the label styling and initial queue hint when no source is configured.'),
toggleField('session_resume', 'Resume Last Session', true, 'Restores queue position and shuffle mode after reload.'),
toggleField('auto_activate', 'Auto Activate on Race Load', true, 'Immediately bring the player shell online on race pages.')
]
},
{
id: 'playback',
title: 'Playback',
subtitle: 'Queue behavior and in-race control feel.',
items: [
selectField('queue_mode', 'Queue Mode', 'shuffle', [
{ value: 'shuffle', label: 'Shuffle' },
{ value: 'sequential', label: 'Sequential' }
], 'Controls how tracks are ordered during playback.'),
toggleField('show_album_art', 'Show Album Art', true, 'Display album cover artwork in the inline now-playing card.'),
toggleField('mute_native_station', 'Mute Native Nitro Type Station', true, 'Silence the built-in race station when this player is active.'),
numberField('progress_tick_ms', 'Progress Tick (ms)', 1000, 'How often the inline player UI refreshes progress text.', 250, 5000, '250')
]
},
{
id: 'advanced',
title: 'Advanced',
subtitle: 'Debug and diagnostic controls.',
items: [
toggleField('debug_logging', 'Debug Logging', false, 'Enable verbose console logging for troubleshooting.')
]
}
]
},
{
id: 'bot-hunter',
label: 'Bot Hunter',
shortLabel: 'BH',
accent: '#d62f3a',
description: 'Bot Hunter observation controls, observer identity, and pattern-analysis presentation.',
hiddenUnlessInstalled: true,
previewSections: [
{
id: 'general',
title: 'General',
subtitle: 'Main enablement and capture behavior.',
items: [
toggleField('enabled', 'Enable Bot Hunter', true, 'Temporarily toggles Bot Hunter capture on and off.'),
toggleField('self_observe_mode', 'Self Observe Mode', true, 'Includes your own races in the payload for testing.'),
toggleField('show_floating_toggle', 'Show Floating Toggle', true, 'Keeps the in-page Bot Hunter button visible.'),
toggleField('enable_logging', 'Enable Logging', true, 'Writes Bot Hunter diagnostics to the console.')
]
},
{
id: 'observer',
title: 'Observer',
subtitle: 'Observer identity and payload metadata.',
items: [
textField('observer_id', 'Observer ID', 'generated-on-demand', 'Visual placeholder for the observer identity field.', 'Observer UUID'),
textField('api_endpoint', 'API Endpoint', 'https://ntstartrack.org/api/bot-observations', 'Current destination for submitted observations.'),
toggleField('use_keepalive', 'Use Keepalive on Exit', true, 'Send pending data during page unload or route exit.'),
noteField('This tab is ideal for a later "copy observer ID" button once behavior wiring starts.', 'info')
]
},
{
id: 'analysis',
title: 'Analysis',
subtitle: 'Pattern heuristics and suspicious-race review.',
items: [
toggleField('detect_1000ms_pattern', 'Detect 1000ms Pattern', true, 'Flag repeated speed changes near one-second boundaries.'),
toggleField('detect_excessive_smoothing', 'Detect Excessive Smoothing', true, 'Flag unnaturally smooth speed transitions.'),
numberField('boundary_tolerance_ms', 'Boundary Tolerance (ms)', 100, 'Tolerance used when checking one-second boundaries.', 25, 250, '5'),
actionField('preview_recent_observations', 'Preview Recent Observations', 'secondary', 'Layout-only action card for a future review drawer.')
]
}
]
}
];
const MODULE_META = Object.create(null);
MODULE_LIBRARY.forEach((module) => {
MODULE_META[module.id] = module;
});
// Section defs are now read from the manifest (manifest.sections) — no hardcoded overrides needed.
const RACE_OPTIONS_THEME_FONT_FAMILY_DEFAULT_CSS = '"Roboto Mono", "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace';
const RACE_OPTIONS_THEME_FONT_WEIGHT_DEFAULT = 400;
const RACE_OPTIONS_THEME_DARK_MODE_FOREGROUND = '#E7EEF8';
const RACE_OPTIONS_THEME_DARK_MODE_FOREGROUND_ACTIVE = '#101623';
const RACE_OPTIONS_THEME_DARK_MODE_FOREGROUND_TYPED = '#8FA3BA';
const RACE_OPTIONS_THEME_DARK_MODE_BACKGROUND = '#0A121E';
const RACE_OPTIONS_THEME_DARK_MODE_BACKGROUND_ACTIVE = '#1C99F4';
const RACE_OPTIONS_THEME_DARK_MODE_BACKGROUND_INCORRECT = '#D62F3A';
const RACE_OPTIONS_THEME_FONT_FAMILY_PRESETS = [
{ value: '__default__', label: 'Default', css: RACE_OPTIONS_THEME_FONT_FAMILY_DEFAULT_CSS },
{ value: 'roboto_mono', label: 'Roboto Mono', css: '"Roboto Mono", "Courier New", Courier, monospace' },
{ value: 'montserrat', label: 'Montserrat', css: '"Montserrat", "Helvetica Neue", Helvetica, Arial, sans-serif' },
{ value: 'poppins', label: 'Poppins', css: '"Poppins", "Segoe UI", Tahoma, sans-serif' },
{ value: 'nunito', label: 'Nunito', css: '"Nunito", "Trebuchet MS", sans-serif' },
{ value: 'oswald', label: 'Oswald', css: '"Oswald", "Arial Narrow", sans-serif' },
{ value: 'space_mono', label: 'Space Mono', css: '"Space Mono", "Courier New", monospace' }
];
const RACE_OPTIONS_THEME_FONT_SIZE_PRESETS = [
{ value: '__default__', label: 'Default', px: 18 },
{ value: '8', label: '8px', px: 8 },
{ value: '10', label: '10px', px: 10 },
{ value: '12', label: '12px', px: 12 },
{ value: '14', label: '14px', px: 14 },
{ value: '16', label: '16px', px: 16 },
{ value: '18', label: '18px', px: 18 },
{ value: '20', label: '20px', px: 20 },
{ value: '24', label: '24px', px: 24 },
{ value: '28', label: '28px', px: 28 },
{ value: '32', label: '32px', px: 32 },
{ value: '36', label: '36px', px: 36 }
];
// Section defaults are now computed from item metadata by mountGenericSection.
function normalizePath(pathname) {
if (!pathname || pathname === '/') return '/';
return pathname.replace(/\/+$/, '') || '/';
}
function isModMenuRoute(pathname = window.location.pathname) {
return normalizePath(pathname) === MOD_MENU_PATH;
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function titleCaseKey(value) {
return String(value || '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, (m) => m.toUpperCase());
}
function slugify(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'section';
}
function readJson(key, fallback = null) {
try {
const raw = localStorage.getItem(key);
if (raw == null) return fallback;
return JSON.parse(raw);
} catch {
return fallback;
}
}
function writeJson(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// ignore storage errors
}
}
function getPreviewStorageKey(moduleId, fieldKey) {
return `${PREVIEW_VALUE_PREFIX}${moduleId}:${fieldKey}`;
}
function getManifestStorageKey(moduleId, fieldKey) {
return `ntcfg:${moduleId}:${fieldKey}`;
}
function emitManifestChange(moduleId, fieldKey, value) {
try {
document.dispatchEvent(new CustomEvent('ntcfg:change', {
detail: {
script: moduleId,
key: fieldKey,
value
}
}));
} catch {
// ignore custom event failures
}
}
function convertManifestSetting(fieldKey, meta) {
const inputType = String(meta?.type || 'text').toLowerCase();
const common = {
key: fieldKey,
label: meta?.label || titleCaseKey(fieldKey),
default: meta?.default,
help: meta?.description || meta?.help || ''
};
// Pass through extended metadata when present
if (meta?.visibleWhen) common.visibleWhen = meta.visibleWhen;
if (meta?.compound) common.compound = meta.compound;
if (meta?.warn) common.warn = meta.warn;
if (meta?.presets) common.presets = meta.presets;
if (meta?.normalize) common.normalize = meta.normalize;
if (meta?.layout) common.layout = meta.layout;
if (inputType === 'boolean') {
return Object.assign({}, common, { type: 'toggle', default: Boolean(meta?.default) });
}
if (inputType === 'color') {
return Object.assign({}, common, { type: 'color', default: meta?.default || '#1C99F4' });
}
if (inputType === 'number' || inputType === 'range') {
return Object.assign({}, common, {
type: 'number',
min: meta?.min ?? null,
max: meta?.max ?? null,
step: meta?.step ?? '1'
});
}
if (inputType === 'select') {
return Object.assign({}, common, {
type: 'select',
options: Array.isArray(meta?.options) ? meta.options.map((option) => ({
value: option?.value,
label: option?.label ?? String(option?.value ?? '')
})) : []
});
}
return Object.assign({}, common, {
type: 'text',
placeholder: meta?.placeholder || ''
});
}
function moduleFromManifest(manifest) {
const base = MODULE_META[manifest.id] || {
id: manifest.id,
label: manifest.name || titleCaseKey(manifest.id),
shortLabel: titleCaseKey(manifest.id).slice(0, 2).toUpperCase(),
accent: '#1c99f4',
description: manifest.description || `${manifest.name || titleCaseKey(manifest.id)} settings.`
};
const grouped = new Map();
const settings = manifest?.settings && typeof manifest.settings === 'object' ? manifest.settings : {};
// Collect compound child keys so they are not rendered as standalone items
const compoundChildKeys = new Set();
Object.entries(settings).forEach(([, meta]) => {
if (Array.isArray(meta?.compound)) {
meta.compound.forEach((child) => {
if (child?.key) compoundChildKeys.add(child.key);
});
}
});
Object.entries(settings).forEach(([fieldKey, meta]) => {
if (compoundChildKeys.has(fieldKey)) return; // skip: rendered inline by its parent
const groupName = meta?.group || 'General';
if (!grouped.has(groupName)) grouped.set(groupName, []);
grouped.get(groupName).push(convertManifestSetting(fieldKey, meta));
});
// Build a lookup from manifest-provided section metadata (subtitle, ordering)
const sectionMeta = new Map();
if (Array.isArray(manifest.sections)) {
manifest.sections.forEach((sec, i) => sectionMeta.set(sec.id || slugify(sec.title || ''), { index: i, ...sec }));
}
const sections = Array.from(grouped.entries()).map(([groupName, items]) => {
const sectionId = slugify(groupName);
const meta = sectionMeta.get(sectionId);
const sec = {
id: sectionId,
title: meta?.title || groupName,
subtitle: meta?.subtitle || `${items.length} synced ${items.length === 1 ? 'setting' : 'settings'} from ${manifest.name || base.label}.`,
items
};
// Pass through section-level metadata
if (meta?.layout) sec.layout = meta.layout;
if (meta?.preview) sec.preview = meta.preview;
if (meta?.resetButton != null) sec.resetButton = meta.resetButton;
return sec;
});
// Sort sections by manifest-defined order if available
if (sectionMeta.size > 0) {
sections.sort((a, b) => {
const ai = sectionMeta.has(a.id) ? sectionMeta.get(a.id).index : Number.MAX_SAFE_INTEGER;
const bi = sectionMeta.has(b.id) ? sectionMeta.get(b.id).index : Number.MAX_SAFE_INTEGER;
return ai - bi;
});
}
if (!sections.length) {
sections.push({
id: 'general',
title: 'General',
subtitle: `${manifest.name || base.label} is registered, but no grouped settings were provided yet.`,
items: [
noteField('This manifest was detected, but it did not expose any structured settings yet.', 'warning')
]
});
}
return Object.assign({}, base, {
source: 'manifest',
manifestVersion: manifest.version || '',
sections
});
}
function readManifestModules() {
const modules = [];
try {
Object.keys(localStorage).forEach((key) => {
if (!key.startsWith(MANIFEST_PREFIX)) return;
const manifest = readJson(key, null);
if (!manifest || !manifest.id) return;
modules.push(moduleFromManifest(manifest));
});
} catch {
return [];
}
const orderIndex = new Map(MODULE_LIBRARY.map((module, index) => [module.id, index]));
modules.sort((a, b) => {
const left = orderIndex.has(a.id) ? orderIndex.get(a.id) : Number.MAX_SAFE_INTEGER;
const right = orderIndex.has(b.id) ? orderIndex.get(b.id) : Number.MAX_SAFE_INTEGER;
if (left !== right) return left - right;
return a.label.localeCompare(b.label);
});
return modules;
}
function buildPreviewModule(module) {
return {
id: module.id,
label: module.label,
shortLabel: module.shortLabel,
accent: module.accent,
description: module.description,
source: 'preview',
sections: module.previewSections
};
}
function buildVisibleModules() {
const manifestModules = readManifestModules();
if (!PREVIEW_FALLBACK_ENABLED) return applyCustomTabOrder(manifestModules);
const manifestById = new Map(manifestModules.map((module) => [module.id, module]));
const modules = [];
MODULE_LIBRARY.forEach((module) => {
const manifest = manifestById.get(module.id);
if (manifest) {
modules.push(manifest);
} else if (!module.hiddenUnlessInstalled) {
modules.push(buildPreviewModule(module));
}
});
manifestModules.forEach((module) => {
if (!MODULE_META[module.id]) {
modules.push(module);
}
});
return applyCustomTabOrder(modules);
}
function applyCustomTabOrder(modules) {
try {
const savedOrder = JSON.parse(localStorage.getItem('ntmods:tab-order') || 'null');
if (Array.isArray(savedOrder) && savedOrder.length) {
const byId = new Map(modules.map((m) => [m.id, m]));
const ordered = [];
savedOrder.forEach((id) => {
const m = byId.get(id);
if (m) {
ordered.push(m);
byId.delete(id);
}
});
byId.forEach((m) => ordered.push(m));
return ordered;
}
} catch { /* ignore */ }
return modules;
}
function getRenderableSections(module) {
return Array.isArray(module?.sections) ? module.sections : [];
}
function readUiState() {
return readJson(UI_STATE_KEY, {}) || {};
}
function writeUiState(nextState) {
writeJson(UI_STATE_KEY, nextState);
}
function parseHashSelection() {
const hash = String(window.location.hash || '').replace(/^#/, '').trim();
if (!hash) return null;
const [moduleId, sectionId] = hash.split('/');
if (!moduleId) return null;
return { moduleId, sectionId: sectionId || '' };
}
function getInitialSelection(modules) {
const stored = readUiState();
const fromHash = parseHashSelection();
const candidates = [fromHash, stored].filter(Boolean);
for (const candidate of candidates) {
const module = modules.find((entry) => entry.id === candidate.moduleId);
if (!module) continue;
const moduleSections = getRenderableSections(module);
const section = moduleSections.find((entry) => entry.id === candidate.sectionId) || moduleSections[0];
return {
activeModuleId: module.id,
activeSectionId: section ? section.id : ''
};
}
const firstModule = modules[0] || null;
const firstSections = getRenderableSections(firstModule);
return {
activeModuleId: firstModule ? firstModule.id : '',
activeSectionId: firstSections[0] ? firstSections[0].id : ''
};
}
function getFieldValue(module, item) {
if (!item || !item.key) return item?.default ?? '';
const storageKey = item.storageKey
|| (module.source === 'manifest'
? getManifestStorageKey(module.id, item.key)
: getPreviewStorageKey(module.id, item.key));
try {
const raw = localStorage.getItem(storageKey);
if (raw == null) return item.default;
return JSON.parse(raw);
} catch {
return item.default;
}
}
function setFieldValue(module, item, value) {
if (!item || !item.key) return;
const storageKey = item.storageKey
|| (module.source === 'manifest'
? getManifestStorageKey(module.id, item.key)
: getPreviewStorageKey(module.id, item.key));
try {
localStorage.setItem(storageKey, JSON.stringify(value));
} catch {
// ignore storage errors
}
if (module.source === 'manifest') {
emitManifestChange(module.id, item.key, value);
}
// Linked fields: emit change event for the target module so the badge script picks it up.
if (item.emitModule && item.emitKey) {
emitManifestChange(item.emitModule, item.emitKey, value);
}
}
function coerceInputValue(element, item) {
if (!element || !item) return null;
if (item.type === 'toggle') return !!element.checked;
if (item.type === 'number') {
const parsed = Number(element.value);
return Number.isFinite(parsed) ? parsed : (item.default ?? 0);
}
return String(element.value ?? '');
}
function createElement(tagName, className = '', textContent = null) {
const element = document.createElement(tagName);
if (className) element.className = className;
if (textContent != null) element.textContent = textContent;
return element;
}
function getModuleFieldMap(module) {
const fieldMap = new Map();
(module?.sections || []).forEach((section) => {
(section?.items || []).forEach((item) => {
if (item?.key) fieldMap.set(item.key, item);
});
});
return fieldMap;
}
function resolveModuleFieldItem(fieldMap, key, fallback = {}) {
return fieldMap.get(key) || Object.assign({
key,
label: titleCaseKey(key),
default: '',
type: 'text',
help: ''
}, fallback);
}
function readModuleFieldValue(module, fieldMap, key, fallback = {}) {
return getFieldValue(module, resolveModuleFieldItem(fieldMap, key, fallback));
}
function writeModuleFieldValue(module, fieldMap, key, value, fallback = {}) {
setFieldValue(module, resolveModuleFieldItem(fieldMap, key, fallback), value);
}
function normalizeRaceOptionsHexColorValue(value, fallback = '#FFFFFF') {
const normalizedFallback = /^#[0-9A-Fa-f]{6}$/.test(String(fallback || '').trim())
? String(fallback).toUpperCase()
: '#FFFFFF';
const raw = String(value || '').trim();
if (/^#[0-9A-Fa-f]{6}$/.test(raw)) return raw.toUpperCase();
if (/^#[0-9A-Fa-f]{3}$/.test(raw)) {
return `#${raw[1]}${raw[1]}${raw[2]}${raw[2]}${raw[3]}${raw[3]}`.toUpperCase();
}
return normalizedFallback;
}
function hexToRgba(hex, alpha = 1) {
const h = normalizeRaceOptionsHexColorValue(hex, '#FFFFFF');
const r = parseInt(h.slice(1, 3), 16);
const g = parseInt(h.slice(3, 5), 16);
const b = parseInt(h.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${Math.min(1, Math.max(0, alpha))})`;
}
function hexToRgbChannels(hex) {
const h = normalizeRaceOptionsHexColorValue(hex, '#FFFFFF');
return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
}
function blendedLuminance(fgHex, bgHex, alpha) {
const [fr, fg, fb] = hexToRgbChannels(fgHex);
const [br, bg, bb] = hexToRgbChannels(bgHex);
const a = Math.min(1, Math.max(0, alpha));
const r = (fr * a + br * (1 - a)) / 255;
const g = (fg * a + bg * (1 - a)) / 255;
const b = (fb * a + bb * (1 - a)) / 255;
const linear = (ch) => (ch <= 0.03928 ? ch / 12.92 : Math.pow((ch + 0.055) / 1.055, 2.4));
return (0.2126 * linear(r)) + (0.7152 * linear(g)) + (0.0722 * linear(b));
}
function normalizeRaceOptionsHighlightOpacity(value, fallback = 0.5) {
const normalizedFallback = Number.isFinite(Number(fallback)) ? Number(fallback) : 0.5;
const parsed = Number(value);
if (!Number.isFinite(parsed)) return normalizedFallback;
return Math.min(1, Math.max(0.05, Math.round(parsed * 100) / 100));
}
function normalizeRaceOptionsRainbowSpeedSeconds(value, fallback = 10) {
const normalizedFallback = Number.isFinite(Number(fallback)) ? Number(fallback) : 10;
const parsed = Number(value);
if (!Number.isFinite(parsed)) return normalizedFallback;
return Math.min(60, Math.max(1, parsed));
}
function normalizeRaceOptionsThemeFontSizePx(value, fallback = 18) {
const normalizedFallback = Number.isFinite(Number(fallback)) ? Number(fallback) : 18;
const parsed = Number(value);
if (!Number.isFinite(parsed)) return normalizedFallback;
return Math.min(72, Math.max(8, Math.round(parsed)));
}
function normalizeRaceOptionsThemeFontFamilyValue(value, fallback = RACE_OPTIONS_THEME_FONT_FAMILY_DEFAULT_CSS) {
const raw = String(value ?? '').trim();
if (!raw) return String(fallback);
const cleaned = raw.replace(/[{}<>;\r\n]/g, '').trim().slice(0, 180);
return cleaned || String(fallback);
}
function normalizeRaceOptionsThemePresetValue(value, presets, fallback = '__default__') {
const raw = String(value ?? '').trim();
if (Array.isArray(presets) && presets.some((preset) => preset?.value === raw)) {
return raw;
}
return fallback;
}
function getRaceOptionsThemeOptions(module, fieldMap) {
const fontFamilyPreset = normalizeRaceOptionsThemePresetValue(
readModuleFieldValue(module, fieldMap, 'THEME_FONT_FAMILY_PRESET', { default: '__default__' }),
RACE_OPTIONS_THEME_FONT_FAMILY_PRESETS
);
const fontSizePreset = normalizeRaceOptionsThemePresetValue(
readModuleFieldValue(module, fieldMap, 'THEME_FONT_SIZE_PRESET', { default: '__default__' }),
RACE_OPTIONS_THEME_FONT_SIZE_PRESETS
);
const singleLineFontSizePreset = normalizeRaceOptionsThemePresetValue(
readModuleFieldValue(module, fieldMap, 'THEME_SINGLE_LINE_FONT_SIZE_PRESET', { default: '__default__' }),
RACE_OPTIONS_THEME_FONT_SIZE_PRESETS
);
const fontFamilyPresetOption = RACE_OPTIONS_THEME_FONT_FAMILY_PRESETS.find((preset) => preset.value === fontFamilyPreset) || RACE_OPTIONS_THEME_FONT_FAMILY_PRESETS[0];
const fontSizePresetOption = RACE_OPTIONS_THEME_FONT_SIZE_PRESETS.find((preset) => preset.value === fontSizePreset) || RACE_OPTIONS_THEME_FONT_SIZE_PRESETS[0];
const singleLineFontSizePresetOption = RACE_OPTIONS_THEME_FONT_SIZE_PRESETS.find((preset) => preset.value === singleLineFontSizePreset) || RACE_OPTIONS_THEME_FONT_SIZE_PRESETS[0];
const darkModeEnabled = !!readModuleFieldValue(module, fieldMap, 'THEME_ENABLE_DARK_MODE', { default: false, type: 'toggle' });
const darkModeSyncSystem = !!readModuleFieldValue(module, fieldMap, 'THEME_DARK_MODE_SYNC_SYSTEM', { default: false, type: 'toggle' });
const darkModeEffective = darkModeSyncSystem
? !!(typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: dark)').matches)
: darkModeEnabled;
return {
darkModeEnabled,
darkModeSyncSystem,
darkModeEffective,
foreground: normalizeRaceOptionsHexColorValue(readModuleFieldValue(module, fieldMap, 'THEME_COLOR_FOREGROUND', { default: '#FFFFFF' }), '#FFFFFF'),
foregroundActive: normalizeRaceOptionsHexColorValue(readModuleFieldValue(module, fieldMap, 'THEME_COLOR_FOREGROUND_ACTIVE', { default: '#000000' }), '#000000'),
foregroundTyped: normalizeRaceOptionsHexColorValue(readModuleFieldValue(module, fieldMap, 'THEME_COLOR_FOREGROUND_TYPED', { default: '#5B5B5B' }), '#5B5B5B'),
background: normalizeRaceOptionsHexColorValue(readModuleFieldValue(module, fieldMap, 'THEME_COLOR_BACKGROUND', { default: '#000000' }), '#000000'),
backgroundActive: normalizeRaceOptionsHexColorValue(readModuleFieldValue(module, fieldMap, 'THEME_COLOR_BACKGROUND_ACTIVE', { default: '#FFFFFF' }), '#FFFFFF'),
backgroundIncorrect: normalizeRaceOptionsHexColorValue(readModuleFieldValue(module, fieldMap, 'THEME_COLOR_BACKGROUND_INCORRECT', { default: '#FF0000' }), '#FF0000'),
overrideForeground: !!readModuleFieldValue(module, fieldMap, 'THEME_OVERRIDE_FOREGROUND', { default: false, type: 'toggle' }),
overrideForegroundActive: !!readModuleFieldValue(module, fieldMap, 'THEME_OVERRIDE_FOREGROUND_ACTIVE', { default: false, type: 'toggle' }),
overrideForegroundTyped: !!readModuleFieldValue(module, fieldMap, 'THEME_OVERRIDE_FOREGROUND_TYPED', { default: false, type: 'toggle' }),
hideTypedText: !!readModuleFieldValue(module, fieldMap, 'THEME_HIDE_TYPED_TEXT', { default: false, type: 'toggle' }),
overrideBackground: !!readModuleFieldValue(module, fieldMap, 'THEME_OVERRIDE_BACKGROUND', { default: false, type: 'toggle' }),
overrideBackgroundActive: !!readModuleFieldValue(module, fieldMap, 'THEME_OVERRIDE_BACKGROUND_ACTIVE', { default: false, type: 'toggle' }),
overrideBackgroundIncorrect: !!readModuleFieldValue(module, fieldMap, 'THEME_OVERRIDE_BACKGROUND_INCORRECT', { default: false, type: 'toggle' }),
fontFamilyPreset,
fontFamilyCss: fontFamilyPreset !== '__default__' && typeof fontFamilyPresetOption?.css === 'string'
? normalizeRaceOptionsThemeFontFamilyValue(fontFamilyPresetOption.css, RACE_OPTIONS_THEME_FONT_FAMILY_DEFAULT_CSS)
: null,
fontSizePreset,
fontSizePx: fontSizePreset !== '__default__' && Number.isFinite(Number(fontSizePresetOption?.px))
? normalizeRaceOptionsThemeFontSizePx(fontSizePresetOption.px, 18)
: null,
singleLineFontSizePreset,
singleLineFontSizePx: singleLineFontSizePreset !== '__default__' && Number.isFinite(Number(singleLineFontSizePresetOption?.px))
? normalizeRaceOptionsThemeFontSizePx(singleLineFontSizePresetOption.px, 18)
: null,
fontBold: !!readModuleFieldValue(module, fieldMap, 'THEME_FONT_BOLD', { default: false, type: 'toggle' }),
fontItalic: !!readModuleFieldValue(module, fieldMap, 'THEME_FONT_ITALIC', { default: false, type: 'toggle' }),
fontWeight: !!readModuleFieldValue(module, fieldMap, 'THEME_FONT_BOLD', { default: false, type: 'toggle' })
? Math.min(900, Math.max(100, Math.round(Number(readModuleFieldValue(module, fieldMap, 'THEME_FONT_WEIGHT', { default: 700, type: 'select' })) || 700)))
: RACE_OPTIONS_THEME_FONT_WEIGHT_DEFAULT,
enableRainbowTypedText: !!readModuleFieldValue(module, fieldMap, 'THEME_ENABLE_RAINBOW_TYPED_TEXT', { default: false, type: 'toggle' }),
rainbowTypedTextSpeedSeconds: normalizeRaceOptionsRainbowSpeedSeconds(
readModuleFieldValue(module, fieldMap, 'THEME_RAINBOW_TYPED_TEXT_SPEED_SECONDS', { default: 10, type: 'number' }),
10
)
};
}
function getEffectiveRaceOptionsThemeBackground(module, fieldMap) {
const themeOptions = getRaceOptionsThemeOptions(module, fieldMap);
if (themeOptions.overrideBackground) return normalizeRaceOptionsHexColorValue(themeOptions.background, '#FFFFFF');
if (themeOptions.darkModeEffective) return RACE_OPTIONS_THEME_DARK_MODE_BACKGROUND;
return '#E9EAEB';
}
function getPerfectNitroTextStyle(module, fieldMap, highlightColor, opacity = 0.5) {
const bgHex = getEffectiveRaceOptionsThemeBackground(module, fieldMap);
const fgHex = normalizeRaceOptionsHexColorValue(highlightColor, '#FFFFFF');
const luminance = blendedLuminance(fgHex, bgHex, opacity);
const color = luminance > 0.5 ? '#101623' : '#E7EEF8';
return {
color,
shadow: color === '#101623' ? '0 1px 1px rgba(255, 255, 255, 0.25)' : '0 1px 1px rgba(0, 0, 0, 0.35)'
};
}
function getRaceOptionsPerfectNitroOptions(module, fieldMap) {
return {
enabled: !!readModuleFieldValue(module, fieldMap, 'ENABLE_PERFECT_NITROS', { default: true, type: 'toggle' }),
highlightColor: normalizeRaceOptionsHexColorValue(readModuleFieldValue(module, fieldMap, 'PERFECT_NITRO_HIGHLIGHT_COLOR', { default: '#FFFFFF' }), '#FFFFFF'),
enableHighlight: !!readModuleFieldValue(module, fieldMap, 'PERFECT_NITRO_ENABLE_HIGHLIGHT', { default: true, type: 'toggle' }),
italic: !!readModuleFieldValue(module, fieldMap, 'PERFECT_NITRO_ITALIC', { default: false, type: 'toggle' }),
rainbow: !!readModuleFieldValue(module, fieldMap, 'PERFECT_NITRO_RAINBOW', { default: false, type: 'toggle' }),
highlightOpacity: normalizeRaceOptionsHighlightOpacity(readModuleFieldValue(module, fieldMap, 'PERFECT_NITRO_HIGHLIGHT_OPACITY', { default: 0.5, type: 'select' }), 0.5),
overrideTextColor: !!readModuleFieldValue(module, fieldMap, 'PERFECT_NITRO_OVERRIDE_TEXT_COLOR', { default: false, type: 'toggle' }),
textColor: normalizeRaceOptionsHexColorValue(readModuleFieldValue(module, fieldMap, 'PERFECT_NITRO_TEXT_COLOR', { default: '#FFFFFF' }), '#FFFFFF')
};
}
function renderPerfectNitroPreview(preview, module, fieldMap) {
const previewText = 'the quick brown fox jumps';
const highlightWord = 'jumps';
const highlightStart = previewText.indexOf(highlightWord);
const highlightEnd = highlightStart + highlightWord.length;
const options = getRaceOptionsPerfectNitroOptions(module, fieldMap);
const themeOptions = getRaceOptionsThemeOptions(module, fieldMap);
preview.innerHTML = '';
if (themeOptions.darkModeEffective) {
preview.style.background = RACE_OPTIONS_THEME_DARK_MODE_BACKGROUND;
preview.style.color = RACE_OPTIONS_THEME_DARK_MODE_FOREGROUND;
} else if (themeOptions.overrideBackground) {
preview.style.background = themeOptions.background;
preview.style.color = '';
} else {
preview.style.background = '';
preview.style.color = '';
}
preview.style.fontFamily = themeOptions.fontFamilyCss || '';
const bgRgba = options.enableHighlight ? hexToRgba(options.highlightColor, options.highlightOpacity) : null;
const textStyle = options.overrideTextColor
? { color: options.textColor, shadow: null }
: options.enableHighlight
? getPerfectNitroTextStyle(module, fieldMap, options.highlightColor, options.highlightOpacity)
: { color: null, shadow: null };
Array.from(previewText).forEach((char, index) => {
const span = createElement('span');
span.textContent = char;
const isHighlighted = index >= highlightStart && index < highlightEnd;
if (isHighlighted) {
if (bgRgba) span.style.backgroundColor = bgRgba;
if (textStyle.color) span.style.color = textStyle.color;
if (textStyle.shadow) span.style.textShadow = textStyle.shadow;
if (options.italic) span.style.fontStyle = 'italic';
if (options.rainbow) span.classList.add('ntcfg-pn-preview-rainbow');
} else if (themeOptions.overrideForeground) {
span.style.color = themeOptions.foreground;
} else if (themeOptions.darkModeEffective) {
span.style.color = RACE_OPTIONS_THEME_DARK_MODE_FOREGROUND;
}
preview.appendChild(span);
});
}
function renderThemePreview(preview, cursorIndex, incorrect, module, fieldMap) {
const options = getRaceOptionsThemeOptions(module, fieldMap);
const previewText = 'The Quick Brown Fox is afraid of The Big Black';
preview.innerHTML = '';
if (options.darkModeEffective) {
preview.style.background = RACE_OPTIONS_THEME_DARK_MODE_BACKGROUND;
preview.style.color = RACE_OPTIONS_THEME_DARK_MODE_FOREGROUND;
} else if (options.overrideBackground) {
preview.style.background = options.background;
preview.style.color = '';
} else {
preview.style.background = '';
preview.style.color = '';
}
preview.style.fontFamily = options.fontFamilyCss || '';
preview.style.fontWeight = String(options.fontWeight || RACE_OPTIONS_THEME_FONT_WEIGHT_DEFAULT);
preview.style.fontStyle = options.fontItalic ? 'italic' : '';
Array.from(previewText).forEach((char, index) => {
const span = createElement('span');
if (index === cursorIndex) {
span.classList.add(incorrect ? 'ntcfg-theme-char-incorrect' : 'ntcfg-theme-char-active');
} else if (index < cursorIndex) {
span.classList.add('ntcfg-theme-char-typed');
if (options.enableRainbowTypedText && !options.hideTypedText) {
span.classList.add('ntcfg-theme-char-rainbow');
span.style.animationDuration = `${options.rainbowTypedTextSpeedSeconds}s`;
span.style.webkitAnimationDuration = `${options.rainbowTypedTextSpeedSeconds}s`;
}
}
span.textContent = char;
if (index > cursorIndex && options.overrideForeground) {
span.style.color = options.foreground;
} else if (index > cursorIndex && options.darkModeEffective) {
span.style.color = RACE_OPTIONS_THEME_DARK_MODE_FOREGROUND;
}
if (index === cursorIndex && options.overrideForegroundActive) {
span.style.color = options.foregroundActive;
} else if (index === cursorIndex && options.darkModeEffective) {
span.style.color = RACE_OPTIONS_THEME_DARK_MODE_FOREGROUND_ACTIVE;
}
if (index < cursorIndex && options.overrideForegroundTyped && !options.enableRainbowTypedText) {
span.style.color = options.foregroundTyped;
span.style.opacity = '1';
} else if (index < cursorIndex && options.darkModeEffective && !options.enableRainbowTypedText) {
span.style.color = RACE_OPTIONS_THEME_DARK_MODE_FOREGROUND_TYPED;
span.style.opacity = '1';
}
if (index < cursorIndex && options.hideTypedText) {
span.style.color = 'transparent';
span.style.webkitTextFillColor = 'transparent';
span.style.textShadow = 'none';
span.style.opacity = '0';
}
if (index === cursorIndex && !incorrect && options.overrideBackgroundActive) {
span.style.background = options.backgroundActive;
} else if (index === cursorIndex && !incorrect && options.darkModeEffective) {
span.style.background = RACE_OPTIONS_THEME_DARK_MODE_BACKGROUND_ACTIVE;
}
if (index === cursorIndex && incorrect && options.overrideBackgroundIncorrect) {
span.style.background = options.backgroundIncorrect;
} else if (index === cursorIndex && incorrect && options.darkModeEffective) {
span.style.background = RACE_OPTIONS_THEME_DARK_MODE_BACKGROUND_INCORRECT;
}
if (Number.isFinite(options.fontSizePx)) {
span.style.fontSize = `${options.fontSizePx}px`;
}
span.style.fontWeight = String(options.fontWeight || RACE_OPTIONS_THEME_FONT_WEIGHT_DEFAULT);
if (options.fontItalic) span.style.fontStyle = 'italic';
preview.appendChild(span);
});
}
// ── Extracted helpers (previously closures inside mountRaceOptionsSection) ──
function createSwitch(checked, onChange) {
const switchRoot = createElement('span', 'ntcfg-switch');
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = !!checked;
input.addEventListener('change', () => onChange(!!input.checked));
const track = createElement('span', 'ntcfg-switch-track');
switchRoot.append(input, track);
return { root: switchRoot, input, track };
}
function createCompactColorControl(initialValue, onChange, wrapperClass = 'ntcfg-theme-color-compact') {
const colorWrap = createElement('span', wrapperClass);
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.className = 'ntcfg-color-picker';
const hexInput = document.createElement('input', 'ntcfg-input');
hexInput.type = 'text';
hexInput.maxLength = 7;
hexInput.placeholder = '#FFFFFF';
const setValue = (value) => {
const normalized = normalizeRaceOptionsHexColorValue(value, '#FFFFFF');
colorInput.value = normalized;
hexInput.value = normalized;
};
setValue(initialValue);
colorInput.addEventListener('input', () => {
const normalized = normalizeRaceOptionsHexColorValue(colorInput.value, colorInput.value);
setValue(normalized);
onChange(normalized);
});
const commitHex = () => {
const normalized = normalizeRaceOptionsHexColorValue(hexInput.value, colorInput.value || '#FFFFFF');
setValue(normalized);
onChange(normalized);
};
hexInput.addEventListener('change', commitHex);
hexInput.addEventListener('blur', commitHex);
colorWrap.append(colorInput, hexInput);
return { root: colorWrap, colorInput, hexInput, setValue };
}
function genericAppendCheckbox(root, opts, ctx) {
const { module, fieldMap, refreshPreviews } = ctx;
const { labelText, key, defaultValue, helpText = '', onChange = null } = opts;
const row = createElement('label', 'ntcfg-checkbox');
const copy = createElement('span', 'ntcfg-checkbox-copy');
copy.appendChild(createElement('span', 'ntcfg-checkbox-label', labelText));
if (helpText) {
copy.appendChild(createElement('span', 'ntcfg-checkbox-help', helpText));
}
const switchControl = createSwitch(
readModuleFieldValue(module, fieldMap, key, { default: defaultValue, type: 'toggle' }),
(checked) => {
writeModuleFieldValue(module, fieldMap, key, checked, { default: defaultValue, type: 'toggle' });
if (typeof onChange === 'function') onChange(checked, row);
refreshPreviews();
}
);
row.append(copy, switchControl.root);
root.appendChild(row);
return { row, input: switchControl.input };
}
function genericAppendNumberField(root, opts, ctx) {
const { module, fieldMap, refreshPreviews } = ctx;
const { labelText, key, defaultValue, helpText = '', minRecommended = null, warningText = '', min = null, max = null, step = '1', onChange = null } = opts;
const field = createElement('label', 'ntcfg-field');
field.appendChild(createElement('div', 'ntcfg-field-title', labelText));
const input = document.createElement('input');
input.className = 'ntcfg-input';
input.type = 'number';
if (min != null) input.min = String(min);
if (max != null) input.max = String(max);
if (step != null) input.step = String(step);
input.value = String(readModuleFieldValue(module, fieldMap, key, { default: defaultValue, type: 'number' }));
const warning = createElement('div', 'ntcfg-field-help ntcfg-field-warning');
warning.hidden = true;
const updateWarningState = (value) => {
const shouldWarn = Number.isFinite(minRecommended) && Number.isFinite(value) && value < minRecommended;
warning.hidden = !shouldWarn;
warning.textContent = shouldWarn ? (warningText || `Warning: values below ${minRecommended} are not recommended.`) : '';
input.classList.toggle('ntcfg-input-warning', shouldWarn);
};
input.addEventListener('input', () => updateWarningState(parseFloat(input.value)));
input.addEventListener('change', () => {
const parsed = parseFloat(input.value);
if (!Number.isFinite(parsed)) return;
writeModuleFieldValue(module, fieldMap, key, parsed, { default: defaultValue, type: 'number' });
updateWarningState(parsed);
if (typeof onChange === 'function') onChange(parsed, field);
refreshPreviews();
});
field.appendChild(input);
if (helpText) field.appendChild(createElement('div', 'ntcfg-field-help', helpText));
if (Number.isFinite(minRecommended)) {
field.appendChild(warning);
updateWarningState(parseFloat(input.value));
}
root.appendChild(field);
return { field, input, warning };
}
function genericAppendSelectField(root, opts, ctx) {
const { module, fieldMap, refreshPreviews } = ctx;
const { labelText, key, defaultValue, options = [], helpText = '', onChange = null } = opts;
const field = createElement('label', 'ntcfg-field');
field.appendChild(createElement('div', 'ntcfg-field-title', labelText));
const select = createElement('select', 'ntcfg-input');
const safeOptions = Array.isArray(options) ? options.filter(Boolean) : [];
const normalizeSelectedValue = (value) => {
const raw = String(value ?? '').trim();
const matched = safeOptions.find((option) => String(option.value) === raw);
return matched ? matched.value : defaultValue;
};
safeOptions.forEach((optionDef) => {
const option = document.createElement('option');
option.value = String(optionDef.value);
option.textContent = String(optionDef.label ?? optionDef.value);
select.appendChild(option);
});
select.value = String(normalizeSelectedValue(readModuleFieldValue(module, fieldMap, key, { default: defaultValue, type: 'select', options })));
select.addEventListener('change', () => {
const normalized = normalizeSelectedValue(select.value);
select.value = String(normalized);
writeModuleFieldValue(module, fieldMap, key, normalized, { default: defaultValue, type: 'select', options });
if (typeof onChange === 'function') onChange(normalized, field);
refreshPreviews();
});
field.appendChild(select);
if (helpText) field.appendChild(createElement('div', 'ntcfg-field-help', helpText));
root.appendChild(field);
return { field, select };
}
function genericAppendTextField(root, opts, ctx) {
const { module, fieldMap, refreshPreviews } = ctx;
const { labelText, key, defaultValue = '', helpText = '', placeholder = '', onChange = null } = opts;
const field = createElement('label', 'ntcfg-field');
field.appendChild(createElement('div', 'ntcfg-field-title', labelText));
const inputRow = createElement('div', 'ntcfg-text-input-row');
inputRow.style.cssText = 'display:flex;gap:6px;align-items:center;';
const input = document.createElement('input');
input.className = 'ntcfg-input';
input.type = 'text';
input.placeholder = placeholder || '';
input.spellcheck = false;
input.value = String(readModuleFieldValue(module, fieldMap, key, { default: defaultValue, type: 'text' }) ?? '');
const saveBtn = createElement('button', 'ntcfg-action ntcfg-action--primary ntcfg-text-save-btn', 'Save');
saveBtn.type = 'button';
saveBtn.style.cssText = 'padding:6px 14px;font-size:12px;white-space:nowrap;flex-shrink:0;';
const clearBtn = createElement('button', 'ntcfg-action ntcfg-action--secondary ntcfg-text-clear-btn', 'Clear');
clearBtn.type = 'button';
clearBtn.style.cssText = 'padding:6px 10px;font-size:12px;white-space:nowrap;flex-shrink:0;';
const commit = (value) => {
writeModuleFieldValue(module, fieldMap, key, value, { default: defaultValue, type: 'text' });
if (typeof onChange === 'function') onChange(value, field);
refreshPreviews();
};
saveBtn.addEventListener('click', () => {
commit(input.value.trim());
showToast('Saved.');
});
clearBtn.addEventListener('click', () => {
input.value = '';
commit('');
showToast('Cleared.');
});
// Also save on Enter key
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
commit(input.value.trim());
showToast('Saved.');
}
});
inputRow.append(input, saveBtn, clearBtn);
field.appendChild(inputRow);
if (helpText) field.appendChild(createElement('div', 'ntcfg-field-help', helpText));
root.appendChild(field);
return { field, input, saveBtn, clearBtn };
}
function genericAppendResetButton(root, labelText, defaults, ctx) {
const { module, fieldMap, rerender } = ctx;
const resetButton = createElement('button', 'ntcfg-action ntcfg-inline-action', labelText);
resetButton.type = 'button';
resetButton.addEventListener('click', () => {
Object.entries(defaults).forEach(([settingKey, value]) => {
writeModuleFieldValue(module, fieldMap, settingKey, value);
});
rerender();
});
root.appendChild(resetButton);
}
function genericAppendThemeSettingToggle(root, labelText, key, defaultValue, ctx, onChange = null) {
const { module, fieldMap, refreshPreviews } = ctx;
const row = createElement('div', 'ntcfg-theme-setting');
row.appendChild(createElement('div', 'ntcfg-theme-setting-title', labelText));
const controls = createElement('div', 'ntcfg-theme-setting-controls');
const switchControl = createSwitch(
readModuleFieldValue(module, fieldMap, key, { default: defaultValue, type: 'toggle' }),
(checked) => {
writeModuleFieldValue(module, fieldMap, key, checked, { default: defaultValue, type: 'toggle' });
if (typeof onChange === 'function') onChange(checked, row);
refreshPreviews();
}
);
controls.appendChild(switchControl.root);
row.appendChild(controls);
root.appendChild(row);
return { row, input: switchControl.input, controls };
}
function genericAppendThemeSettingColor(root, labelText, toggleKey, toggleDefault, colorKey, colorDefault, ctx) {
const { module, fieldMap, refreshPreviews } = ctx;
const row = createElement('div', 'ntcfg-theme-setting');
row.appendChild(createElement('div', 'ntcfg-theme-setting-title', labelText));
const controls = createElement('div', 'ntcfg-theme-setting-controls');
const colorControl = createCompactColorControl(
readModuleFieldValue(module, fieldMap, colorKey, { default: colorDefault, type: 'color' }),
(value) => {
writeModuleFieldValue(module, fieldMap, colorKey, value, { default: colorDefault, type: 'color' });
refreshPreviews();
}
);
const switchControl = createSwitch(
readModuleFieldValue(module, fieldMap, toggleKey, { default: toggleDefault, type: 'toggle' }),
(checked) => {
writeModuleFieldValue(module, fieldMap, toggleKey, checked, { default: toggleDefault, type: 'toggle' });
refreshPreviews();
}
);
controls.append(colorControl.root, switchControl.root);
row.append(controls);
root.appendChild(row);
}
function genericAppendThemeSettingSelect(root, labelText, key, defaultValue, options, ctx, className = 'ntcfg-input ntcfg-theme-input-compact ntcfg-theme-input-select', onChange = null) {
const { module, fieldMap, refreshPreviews } = ctx;
const row = createElement('div', 'ntcfg-theme-setting');
row.appendChild(createElement('div', 'ntcfg-theme-setting-title', labelText));
const controls = createElement('div', 'ntcfg-theme-setting-controls');
const select = createElement('select', className);
const safeOptions = Array.isArray(options) ? options.filter(Boolean) : [];
const normalizeSelectedValue = (value) => {
const raw = String(value ?? '').trim();
const match = safeOptions.find((option) => String(option.value) === raw);
return match ? match.value : defaultValue;
};
safeOptions.forEach((optionDef) => {
const option = document.createElement('option');
option.value = String(optionDef.value);
option.textContent = String(optionDef.label ?? optionDef.value);
select.appendChild(option);
});
select.value = String(normalizeSelectedValue(readModuleFieldValue(module, fieldMap, key, { default: defaultValue, type: 'select', options })));
select.addEventListener('change', () => {
const normalized = normalizeSelectedValue(select.value);
select.value = String(normalized);
writeModuleFieldValue(module, fieldMap, key, normalized, { default: defaultValue, type: 'select', options });
if (typeof onChange === 'function') onChange(normalized, row, select);
refreshPreviews();
});
controls.appendChild(select);
row.appendChild(controls);
root.appendChild(row);
return { row, select, controls };
}
function genericAppendThemeSettingNumber(root, labelText, key, defaultValue, config, ctx) {
const { module, fieldMap, refreshPreviews } = ctx;
const { min = 1, max = 60, step = 1, presets = [], normalizeValue = (value, fallback) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : Number(fallback);
} } = config || {};
const row = createElement('div', 'ntcfg-theme-setting');
row.appendChild(createElement('div', 'ntcfg-theme-setting-title', labelText));
const controls = createElement('div', 'ntcfg-theme-setting-controls');
const input = createElement('input', 'ntcfg-input ntcfg-theme-input-compact ntcfg-theme-input-number');
input.type = 'number';
input.min = String(min);
input.max = String(max);
input.step = String(step);
input.value = String(normalizeValue(readModuleFieldValue(module, fieldMap, key, { default: defaultValue, type: 'number' }), defaultValue));
const presetSelect = createElement('select', 'ntcfg-input ntcfg-theme-input-compact ntcfg-theme-input-preset');
const customPresetValue = '__custom__';
const customOption = document.createElement('option');
customOption.value = customPresetValue;
customOption.textContent = 'Custom';
presetSelect.appendChild(customOption);
const normalizedPresets = Array.isArray(presets)
? presets.map((preset) => {
if (!preset || typeof preset !== 'object') return null;
const value = normalizeValue(preset.value, defaultValue);
return { value, label: String(preset.label || `${value}s`).trim() };
}).filter(Boolean)
: [];
normalizedPresets.forEach((preset) => {
const option = document.createElement('option');
option.value = String(preset.value);
option.textContent = `${preset.label} (${preset.value}s)`;
presetSelect.appendChild(option);
});
const syncPresetFromValue = (value) => {
const matched = normalizedPresets.find((preset) => preset.value === value);
presetSelect.value = matched ? String(matched.value) : customPresetValue;
};
const save = () => {
const normalized = normalizeValue(input.value, defaultValue);
input.value = String(normalized);
syncPresetFromValue(normalized);
writeModuleFieldValue(module, fieldMap, key, normalized, { default: defaultValue, type: 'number' });
refreshPreviews();
};
input.addEventListener('change', save);
input.addEventListener('blur', save);
presetSelect.addEventListener('change', () => {
if (presetSelect.value === customPresetValue) return;
const normalized = normalizeValue(presetSelect.value, defaultValue);
input.value = String(normalized);
writeModuleFieldValue(module, fieldMap, key, normalized, { default: defaultValue, type: 'number' });
refreshPreviews();
});
controls.appendChild(input);
if (normalizedPresets.length) {
controls.appendChild(presetSelect);
syncPresetFromValue(normalizeValue(input.value, defaultValue));
}
row.appendChild(controls);
root.appendChild(row);
return { row, input, presetSelect };
}
// ── Preview registry ──
const PREVIEW_RENDERERS = {
'theme': function (fields, refreshPreviewFns, module, fieldMap) {
const stickyWrapper = createElement('div', 'ntcfg-sticky-preview');
stickyWrapper.appendChild(createElement('div', 'ntcfg-theme-preview-label', 'Preview:'));
const previewGrid = createElement('div', 'ntcfg-theme-preview-grid');
const normalPreview = createElement('div', 'ntcfg-theme-preview');
const incorrectPreview = createElement('div', 'ntcfg-theme-preview');
previewGrid.append(normalPreview, incorrectPreview);
stickyWrapper.appendChild(previewGrid);
fields.appendChild(stickyWrapper);
refreshPreviewFns.push(() => renderThemePreview(normalPreview, 7, false, module, fieldMap));
refreshPreviewFns.push(() => renderThemePreview(incorrectPreview, 7, true, module, fieldMap));
},
'perfect-nitro': function (fields, refreshPreviewFns, module, fieldMap) {
const stickyWrapper = createElement('div', 'ntcfg-sticky-preview');
stickyWrapper.appendChild(createElement('div', 'ntcfg-theme-preview-label', 'Preview:'));
const preview = createElement('div', 'ntcfg-pn-preview');
stickyWrapper.appendChild(preview);
fields.appendChild(stickyWrapper);
refreshPreviewFns.push(() => renderPerfectNitroPreview(preview, module, fieldMap));
}
};
// ── Generic section mount ──
function mountGenericSection(module, section) {
const host = document.querySelector('[data-ntmods-generic-stage]');
if (!host || !module || !section) return;
const fieldMap = getModuleFieldMap(module);
const rerender = () => mountGenericSection(module, section);
const refreshPreviewFns = [];
const refreshPreviews = () => { refreshPreviewFns.forEach((fn) => fn()); };
const ctx = { module, fieldMap, refreshPreviewFns, refreshPreviews, rerender };
const sectionRoot = createElement('div', 'ntmods-ro-panel');
const title = createElement('h2', 'ntcfg-panel-title', section.title);
const subtitle = createElement('p', 'ntcfg-panel-subtitle', section.subtitle || '');
const fields = createElement('div', 'ntcfg-fields');
sectionRoot.append(title, subtitle, fields);
host.innerHTML = '';
host.appendChild(sectionRoot);
// Render preview widget if section declares one
if (section.preview && typeof section.preview === 'object' && section.preview.type) {
const renderer = PREVIEW_RENDERERS[section.preview.type];
if (typeof renderer === 'function') {
renderer(fields, refreshPreviewFns, module, fieldMap);
}
}
const isCompact = section.layout === 'compact';
// Container for compact layout items
let compactRoot = null;
if (isCompact) {
compactRoot = createElement('div', 'ntcfg-theme-settings');
fields.appendChild(compactRoot);
}
// Track rendered elements for visibleWhen
const visibilityMap = new Map(); // key -> { element, item }
const renderedElements = new Map(); // key -> element ref returned
const items = Array.isArray(section.items) ? section.items : [];
items.forEach((item) => {
if (item.type === 'note') {
const noteEl = createElement('div', `ntmods-note ntmods-note--${item.tone || 'info'}`, item.message || '');
(isCompact ? compactRoot : fields).appendChild(noteEl);
return;
}
const targetRoot = isCompact ? compactRoot : fields;
// Handle compound items (toggle with inline child controls)
if (item.compound && Array.isArray(item.compound) && item.compound.length) {
renderCompoundRow(targetRoot, item, ctx);
return;
}
let rendered = null;
if (isCompact) {
// Compact layout uses ntcfg-theme-setting rows
if (item.type === 'toggle') {
rendered = genericAppendThemeSettingToggle(targetRoot, item.label, item.key, item.default, ctx);
} else if (item.type === 'select') {
rendered = genericAppendThemeSettingSelect(targetRoot, item.label, item.key, item.default, item.options || [], ctx);
} else if (item.type === 'number') {
const numConfig = {
min: item.min,
max: item.max,
step: item.step,
presets: item.presets || [],
normalizeValue: item.normalize || undefined
};
rendered = genericAppendThemeSettingNumber(targetRoot, item.label, item.key, item.default, numConfig, ctx);
} else if (item.type === 'color') {
// Render as a compact theme color row (toggle always on)
const row = createElement('div', 'ntcfg-theme-setting');
row.appendChild(createElement('div', 'ntcfg-theme-setting-title', item.label));
const controls = createElement('div', 'ntcfg-theme-setting-controls');
const colorControl = createCompactColorControl(
readModuleFieldValue(module, fieldMap, item.key, { default: item.default, type: 'color' }),
(value) => {
writeModuleFieldValue(module, fieldMap, item.key, value, { default: item.default, type: 'color' });
refreshPreviews();
}
);
controls.appendChild(colorControl.root);
row.appendChild(controls);
targetRoot.appendChild(row);
rendered = { row };
}
} else {
// Standard layout
if (item.type === 'toggle') {
rendered = genericAppendCheckbox(targetRoot, {
labelText: item.label,
key: item.key,
defaultValue: item.default,
helpText: item.help || ''
}, ctx);
} else if (item.type === 'number') {
const warnOpts = item.warn || {};
rendered = genericAppendNumberField(targetRoot, {
labelText: item.label,
key: item.key,
defaultValue: item.default,
helpText: item.help || '',
min: item.min,
max: item.max,
step: item.step || '1',
minRecommended: warnOpts.below != null ? warnOpts.below : null,
warningText: warnOpts.message || ''
}, ctx);
} else if (item.type === 'select') {
rendered = genericAppendSelectField(targetRoot, {
labelText: item.label,
key: item.key,
defaultValue: item.default,
options: item.options || [],
helpText: item.help || ''
}, ctx);
} else if (item.type === 'color') {
rendered = genericAppendCheckbox(targetRoot, {
labelText: item.label,
key: item.key,
defaultValue: item.default,
helpText: item.help || ''
}, ctx);
} else if (item.type === 'text') {
rendered = genericAppendTextField(targetRoot, {
labelText: item.label,
key: item.key,
defaultValue: item.default || '',
helpText: item.help || '',
placeholder: item.placeholder || ''
}, ctx);
} else if (item.type === 'action') {
const btn = createElement('button', `ntcfg-action ntcfg-inline-action ntcfg-action--${item.style || 'secondary'}`, item.label);
btn.type = 'button';
btn.addEventListener('click', () => { showToast('Preview action only for now.'); });
targetRoot.appendChild(btn);
rendered = { row: btn };
}
}
// Store for visibility tracking
if (item.key && rendered) {
const el = rendered.row || rendered.field || null;
if (el) {
renderedElements.set(item.key, el);
}
}
// Handle visibleWhen
if (item.visibleWhen && item.key && rendered) {
const el = rendered.row || rendered.field || null;
if (el) {
visibilityMap.set(item.key, { element: el, condition: item.visibleWhen });
}
}
});
// Apply initial visibleWhen state and wire onChange cascades
visibilityMap.forEach(({ element, condition }) => {
const depKey = condition.key;
const depItem = fieldMap.get(depKey);
if (!depItem) return;
const currentValue = readModuleFieldValue(module, fieldMap, depKey, { default: depItem.default, type: depItem.type });
const shouldShow = condition.eq != null ? currentValue === condition.eq : !!currentValue;
element.hidden = !shouldShow;
});
// Wire up onChange cascades: find items that other items depend on
const depKeys = new Set();
visibilityMap.forEach(({ condition }) => depKeys.add(condition.key));
// For each dependency key, we need to re-check visibility on change
// This is already handled via rerender on toggle changes, but we need to also
// hook into the initial item rendering. Since all toggle changes trigger refreshPreviews,
// we hook into refreshPreviews to also update visibility.
const originalRefreshPreviews = refreshPreviews;
const augmentedRefreshPreviews = () => {
originalRefreshPreviews();
visibilityMap.forEach(({ element, condition }) => {
const depItem = fieldMap.get(condition.key);
if (!depItem) return;
const val = readModuleFieldValue(module, fieldMap, condition.key, { default: depItem.default, type: depItem.type });
const shouldShow = condition.eq != null ? val === condition.eq : !!val;
element.hidden = !shouldShow;
});
};
// Patch the ctx refresh so all child controls use the augmented version
ctx.refreshPreviews = augmentedRefreshPreviews;
refreshPreviewFns.push(() => {
visibilityMap.forEach(({ element, condition }) => {
const depItem = fieldMap.get(condition.key);
if (!depItem) return;
const val = readModuleFieldValue(module, fieldMap, condition.key, { default: depItem.default, type: depItem.type });
const shouldShow = condition.eq != null ? val === condition.eq : !!val;
element.hidden = !shouldShow;
});
});
// Render reset button
if (section.resetButton) {
const label = typeof section.resetButton === 'string'
? section.resetButton
: `Reset ${section.title} to Defaults`;
// Compute defaults from item metadata
const defaults = {};
items.forEach((item) => {
if (item.key && item.default !== undefined) {
defaults[item.key] = item.default;
}
// Also collect compound child defaults
if (item.compound && Array.isArray(item.compound)) {
item.compound.forEach((child) => {
if (child.key && child.default !== undefined) {
defaults[child.key] = child.default;
}
});
}
});
genericAppendResetButton(fields, label, defaults, ctx);
}
refreshPreviews();
}
function renderCompoundRow(root, item, ctx) {
const { module, fieldMap, refreshPreviews } = ctx;
const isCompactLayout = root.classList.contains('ntcfg-theme-settings');
if (isCompactLayout) {
// Compound in compact layout: ntcfg-theme-setting row with inline controls
const row = createElement('div', 'ntcfg-theme-setting');
row.appendChild(createElement('div', 'ntcfg-theme-setting-title', item.label));
const controls = createElement('div', 'ntcfg-theme-setting-controls');
item.compound.forEach((child) => {
if (child.type === 'color') {
const colorControl = createCompactColorControl(
readModuleFieldValue(module, fieldMap, child.key, { default: child.default, type: 'color' }),
(value) => {
writeModuleFieldValue(module, fieldMap, child.key, value, { default: child.default, type: 'color' });
refreshPreviews();
}
);
controls.appendChild(colorControl.root);
} else if (child.type === 'select') {
const safeOptions = Array.isArray(child.options) ? child.options.filter(Boolean) : [];
const select = createElement('select', 'ntcfg-input ntcfg-theme-input-compact ntcfg-theme-input-select');
safeOptions.forEach((optionDef) => {
const option = document.createElement('option');
option.value = String(optionDef.value);
option.textContent = String(optionDef.label ?? optionDef.value);
select.appendChild(option);
});
const normalizeSelectedValue = (value) => {
const raw = String(value ?? '').trim();
const matched = safeOptions.find((option) => String(option.value) === raw);
return matched ? matched.value : child.default;
};
select.value = String(normalizeSelectedValue(readModuleFieldValue(module, fieldMap, child.key, { default: child.default, type: 'select', options: child.options })));
select.addEventListener('change', () => {
const normalized = normalizeSelectedValue(select.value);
select.value = String(normalized);
writeModuleFieldValue(module, fieldMap, child.key, normalized, { default: child.default, type: 'select', options: child.options });
if (child.onChange) child.onChange(normalized, row, select);
refreshPreviews();
});
controls.appendChild(select);
} else if (child.type === 'toggle') {
const switchControl = createSwitch(
readModuleFieldValue(module, fieldMap, child.key, { default: child.default, type: 'toggle' }),
(checked) => {
writeModuleFieldValue(module, fieldMap, child.key, checked, { default: child.default, type: 'toggle' });
if (child.onChange) child.onChange(checked, row);
refreshPreviews();
}
);
controls.appendChild(switchControl.root);
}
});
// The main item itself is the primary toggle
if (item.type === 'toggle') {
const mainSwitch = createSwitch(
readModuleFieldValue(module, fieldMap, item.key, { default: item.default, type: 'toggle' }),
(checked) => {
writeModuleFieldValue(module, fieldMap, item.key, checked, { default: item.default, type: 'toggle' });
refreshPreviews();
}
);
controls.appendChild(mainSwitch.root);
}
row.appendChild(controls);
root.appendChild(row);
} else {
// Compound in standard layout: ntcfg-checkbox row with inline controls
const row = createElement('div', 'ntcfg-checkbox');
const copy = createElement('span', 'ntcfg-checkbox-copy');
copy.append(
createElement('span', 'ntcfg-checkbox-label', item.label),
createElement('span', 'ntcfg-checkbox-help', item.help || '')
);
const controls = createElement('span', 'ntcfg-checkbox-controls');
item.compound.forEach((child) => {
if (child.type === 'color') {
const colorControl = createCompactColorControl(
readModuleFieldValue(module, fieldMap, child.key, { default: child.default, type: 'color' }),
(value) => {
writeModuleFieldValue(module, fieldMap, child.key, value, { default: child.default, type: 'color' });
refreshPreviews();
},
'ntcfg-checkbox-controls'
);
controls.appendChild(colorControl.root);
} else if (child.type === 'select') {
const safeOptions = Array.isArray(child.options) ? child.options.filter(Boolean) : [];
const select = createElement('select', 'ntcfg-input ntcfg-theme-input-compact');
safeOptions.forEach((optionDef) => {
const option = document.createElement('option');
option.value = String(optionDef.value);
option.textContent = String(optionDef.label ?? optionDef.value);
select.appendChild(option);
});
const normalizeSelectedValue = (value) => {
const raw = String(value ?? '').trim();
const matched = safeOptions.find((option) => String(option.value) === raw);
return matched ? matched.value : child.default;
};
select.value = String(normalizeSelectedValue(readModuleFieldValue(module, fieldMap, child.key, { default: child.default, type: 'select', options: child.options })));
select.addEventListener('change', () => {
const normalized = normalizeSelectedValue(select.value);
select.value = String(normalized);
writeModuleFieldValue(module, fieldMap, child.key, normalized, { default: child.default, type: 'select', options: child.options });
refreshPreviews();
});
controls.appendChild(select);
}
});
// Main toggle
if (item.type === 'toggle') {
const mainSwitch = createSwitch(
readModuleFieldValue(module, fieldMap, item.key, { default: item.default, type: 'toggle' }),
(checked) => {
writeModuleFieldValue(module, fieldMap, item.key, checked, { default: item.default, type: 'toggle' });
// Hide/show compound children based on toggle state
item.compound.forEach((child) => {
// Find the child control in controls and toggle visibility
});
refreshPreviews();
}
);
controls.appendChild(mainSwitch.root);
}
row.append(copy, controls);
root.appendChild(row);
}
}
function buildModuleCountPill(value, label) {
return `
<div class="ntmods-cap-stat">
<span class="ntmods-cap-stat-value">${escapeHtml(value)}</span>
<span class="ntmods-cap-stat-label">${escapeHtml(label)}</span>
</div>
`;
}
function getTopTabGrow(module) {
const label = String(module?.label || '');
return Math.max(1, Math.min(2.15, (label.length + 4) / 7.5));
}
function getModuleIconMarkup(module, variant = 'tab') {
const iconClass = variant === 'chip'
? 'ntmods-mod-icon ntmods-mod-icon--chip'
: 'ntmods-mod-icon ntmods-mod-icon--tab';
switch (module?.id) {
case 'race-options':
return `
<svg class="${iconClass}" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6h16M4 12h16M4 18h16"></path>
<circle cx="9" cy="6" r="2"></circle>
<circle cx="15" cy="12" r="2"></circle>
<circle cx="11" cy="18" r="2"></circle>
</svg>
`;
case 'music-player':
return `
<svg class="${iconClass}" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 18.5a2.5 2.5 0 1 1-2.5-2.5A2.5 2.5 0 0 1 10 18.5Zm9-2a2.5 2.5 0 1 1-2.5-2.5A2.5 2.5 0 0 1 19 16.5Z"></path>
<path d="M10 18.5V7.5l9-2v11"></path>
</svg>
`;
case 'bot-flag':
return `
<svg class="${iconClass}" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 20V4"></path>
<path d="M6 5h11l-2.5 3 2.5 3H6"></path>
</svg>
`;
case 'bot-hunter':
return `
<svg class="${iconClass}" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4.25"></circle>
<circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none"></circle>
<path d="M12 3.5V6M12 18V20.5M3.5 12H6M18 12h2.5"></path>
</svg>
`;
case 'leaderboards':
return `
<svg class="${iconClass}" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 20V11M10 20V6M16 20v-8M3 20h18"></path>
</svg>
`;
case 'racer-badges':
return `
<svg class="${iconClass}" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8.5" r="4.25"></circle>
<path d="M9.5 12.5 7.2 20l4.8-2.5 4.8 2.5-2.3-7.5"></path>
</svg>
`;
default:
return `
<svg class="${iconClass}" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6h16M4 12h16M4 18h16"></path>
<circle cx="9" cy="6" r="2"></circle>
<circle cx="15" cy="12" r="2"></circle>
<circle cx="11" cy="18" r="2"></circle>
</svg>
`;
}
}
function buildTopTabHtml(module, isActive) {
return `
<button
type="button"
class="tab ntmods-top-tab${isActive ? ' is-active' : ''}${module.source === 'preview' ? ' is-preview' : ''}"
data-module-id="${escapeHtml(module.id)}"
style="--ntmods-tab-grow:${getTopTabGrow(module)}"
>
<div class="bucket bucket--c bucket--xs">
<div class="bucket-media">
<span class="ntmods-top-tab-glyph">${getModuleIconMarkup(module, 'tab')}</span>
</div>
<div class="bucket-content">${escapeHtml(module.label)}</div>
</div>
</button>
`;
}
function buildSidebarButtonHtml(section, isActive) {
return `
<button type="button" class="ntmods-nav-btn${isActive ? ' is-active' : ''}" data-section-id="${escapeHtml(section.id)}">
<span class="ntmods-nav-btn-title">${escapeHtml(section.title)}</span>
</button>
`;
}
function buildToggleHtml(module, item) {
const value = Boolean(getFieldValue(module, item));
return `
<label class="ntmods-toggle-card">
<span class="ntmods-toggle-copy">
<span class="ntmods-field-title">${escapeHtml(item.label)}</span>
<span class="ntmods-field-help">${escapeHtml(item.help || '')}</span>
</span>
<span class="ntmods-switch">
<input type="checkbox" data-field-key="${escapeHtml(item.key)}" ${value ? 'checked' : ''}>
<span class="ntmods-switch-track"></span>
</span>
</label>
`;
}
function buildSelectOptions(item, value) {
return (item.options || []).map((option) => {
const selected = String(option.value) === String(value) ? 'selected' : '';
return `<option value="${escapeHtml(option.value)}" ${selected}>${escapeHtml(option.label)}</option>`;
}).join('');
}
function buildFieldHtml(module, item) {
const value = getFieldValue(module, item);
if (item.type === 'toggle') {
return buildToggleHtml(module, item);
}
if (item.type === 'note') {
return `<div class="ntmods-note ntmods-note--${escapeHtml(item.tone || 'info')}">${escapeHtml(item.message || '')}</div>`;
}
if (item.type === 'action') {
return `
<div class="ntmods-action-card">
<button type="button" class="ntmods-action-btn ntmods-action-btn--${escapeHtml(item.style || 'secondary')}" data-action-key="${escapeHtml(item.key)}">
${escapeHtml(item.label)}
</button>
${item.help ? `<div class="ntmods-field-help">${escapeHtml(item.help)}</div>` : ''}
</div>
`;
}
if (item.type === 'color') {
const normalized = String(value || item.default || '#1C99F4');
return `
<label class="ntmods-field-card">
<span class="ntmods-field-title">${escapeHtml(item.label)}</span>
<span class="ntmods-color-row">
<input type="color" class="ntmods-color-picker" value="${escapeHtml(normalized)}" data-field-key="${escapeHtml(item.key)}" data-field-type="color">
<input type="text" class="ntmods-input ntmods-color-input" value="${escapeHtml(normalized)}" data-field-key="${escapeHtml(item.key)}" data-field-type="color-text" spellcheck="false">
</span>
${item.help ? `<span class="ntmods-field-help">${escapeHtml(item.help)}</span>` : ''}
</label>
`;
}
if (item.type === 'select') {
return `
<label class="ntmods-field-card">
<span class="ntmods-field-title">${escapeHtml(item.label)}</span>
<select class="ntmods-input" data-field-key="${escapeHtml(item.key)}" data-field-type="select">
${buildSelectOptions(item, value)}
</select>
${item.help ? `<span class="ntmods-field-help">${escapeHtml(item.help)}</span>` : ''}
</label>
`;
}
if (item.type === 'number') {
const minAttr = item.min == null ? '' : `min="${escapeHtml(item.min)}"`;
const maxAttr = item.max == null ? '' : `max="${escapeHtml(item.max)}"`;
const stepAttr = item.step == null ? '' : `step="${escapeHtml(item.step)}"`;
return `
<label class="ntmods-field-card">
<span class="ntmods-field-title">${escapeHtml(item.label)}</span>
<input type="number" class="ntmods-input" value="${escapeHtml(value)}" data-field-key="${escapeHtml(item.key)}" data-field-type="number" ${minAttr} ${maxAttr} ${stepAttr}>
${item.help ? `<span class="ntmods-field-help">${escapeHtml(item.help)}</span>` : ''}
</label>
`;
}
return `
<label class="ntmods-field-card">
<span class="ntmods-field-title">${escapeHtml(item.label)}</span>
<input type="text" class="ntmods-input" value="${escapeHtml(value ?? '')}" data-field-key="${escapeHtml(item.key)}" data-field-type="text" placeholder="${escapeHtml(item.placeholder || '')}" spellcheck="false">
${item.help ? `<span class="ntmods-field-help">${escapeHtml(item.help)}</span>` : ''}
</label>
`;
}
function buildSectionHtml(module, section) {
// Manifest modules use the generic mount (DOM rendered after innerHTML)
if (module?.source === 'manifest') {
return `<div class="ntmods-generic-stage" data-ntmods-generic-stage="1"></div>`;
}
// Preview (non-manifest) modules use the card layout fallback
const settingItems = section.items.filter((item) => item.type !== 'note');
const toggleCount = settingItems.filter((item) => item.type === 'toggle').length;
const inputCount = settingItems.length - toggleCount;
const modeLabel = 'Preview Layout';
return `
<div class="ntmods-stage-intro">
<div class="ntmods-stage-intro-copy">
<h2 class="ntmods-stage-title">${escapeHtml(section.title)}</h2>
<p class="ntmods-stage-subtitle">${escapeHtml(section.subtitle || '')}</p>
</div>
<div class="ntmods-stage-pills">
<span class="ntmods-pill">${escapeHtml(modeLabel)}</span>
<span class="ntmods-pill">${escapeHtml(toggleCount)} toggles</span>
<span class="ntmods-pill">${escapeHtml(inputCount)} inputs</span>
</div>
</div>
<div class="ntmods-grid">
${section.items.map((item) => buildFieldHtml(module, item)).join('')}
</div>
`;
}
function buildPageHtml(modules, activeModuleId, activeSectionId) {
const activeModule = modules.find((module) => module.id === activeModuleId) || modules[0] || null;
const activeModuleSections = getRenderableSections(activeModule);
const activeSection = activeModule
? activeModuleSections.find((section) => section.id === activeSectionId) || activeModuleSections[0]
: null;
if (!activeModule || !activeSection) {
return `
<section id="ntmods-app" class="card card--b card--o card--shadow card--f card--grit well well--b well--l ntmods-shell">
<div class="card-cap bg--gradient ntmods-cap">
<h1 class="h2 tbs ntmods-cap-title">Mod Menu</h1>
</div>
<div class="well--p well--l_p">
<div class="ntmods-empty">No installed mods were detected yet.</div>
</div>
</section>
`;
}
return `
<section id="ntmods-app" class="card card--b card--o card--shadow card--f card--grit well well--b well--l ntmods-shell">
<div class="card-cap bg--gradient ntmods-cap">
<h1 class="h2 tbs ntmods-cap-title">Mod Menu</h1>
</div>
<div class="well--p well--l_p">
<div class="tabs tabs--a ntmods-top-tabs">
${modules.map((module) => buildTopTabHtml(module, module.id === activeModule.id)).join('')}
</div>
<div class="ntmods-module-card">
<div class="ntmods-module-head">
<div class="ntmods-module-head-copy">
<div class="ntmods-module-overline">${activeModule.source === 'manifest' ? 'Connected Module' : 'Not Installed'}</div>
<h2 class="ntmods-module-title">${escapeHtml(activeModule.label)}</h2>
<p class="ntmods-module-description">${escapeHtml(activeModule.description || '')}</p>
</div>
<div class="ntmods-module-actions">
<button type="button" class="ntmods-rescan-btn" data-action-key="rescan">Rescan Mods</button>
</div>
</div>
${activeModule.source === 'preview' ? `
<div class="ntmods-not-installed">
<div class="ntmods-not-installed-icon">
${getModuleIconMarkup(activeModule, 'large')}
</div>
<h3 class="ntmods-not-installed-title">Not Installed</h3>
<p class="ntmods-not-installed-copy">You don't have <strong>${escapeHtml(activeModule.label)}</strong> installed yet. Install it from Greasyfork to unlock these settings.</p>
${MODULE_META[activeModule.id]?.installUrl ? `
<a href="${escapeHtml(MODULE_META[activeModule.id].installUrl)}" target="_blank" rel="noopener noreferrer" class="ntmods-install-link">
Install ${escapeHtml(activeModule.label)} on Greasyfork →
</a>
` : ''}
</div>
` : `
<div class="ntmods-layout">
<aside class="ntmods-sidebar">
${activeModuleSections.map((section) => buildSidebarButtonHtml(section, section.id === activeSection.id)).join('')}
</aside>
<section class="ntmods-stage">
${buildSectionHtml(activeModule, activeSection)}
</section>
</div>
`}
</div>
</div>
</section>
`;
}
function showToast(message) {
let toast = document.getElementById('ntmods-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'ntmods-toast';
toast.className = 'ntmods-toast';
document.body.appendChild(toast);
}
toast.textContent = message;
toast.classList.add('is-visible');
window.clearTimeout(showToast._timer);
showToast._timer = window.setTimeout(() => {
toast.classList.remove('is-visible');
}, 1800);
}
function ensureStyles() {
if (document.getElementById('ntmods-style')) return;
const style = document.createElement('style');
style.id = 'ntmods-style';
style.textContent = `
html.is-mods-route main.structure-content {
opacity: 0 !important;
visibility: hidden !important;
}
html.is-mods-route main.structure-content.custom-loaded {
opacity: 1 !important;
visibility: visible !important;
transition: opacity 0.16s ease-in;
}
.ntmods-dropdown-item.is-current .dropdown-link {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.ntmods-dropdown-item .dropdown-link .icon {
opacity: 0.92;
}
.ntmods-dropdown-item .dropdown-link .ntmods-inline-icon {
opacity: 1;
color: #9fb2c6 !important;
fill: currentColor !important;
}
.ntmods-dropdown-item .dropdown-link:hover .ntmods-inline-icon,
.ntmods-dropdown-item.is-current .dropdown-link .ntmods-inline-icon {
color: #c8d5e2 !important;
fill: currentColor !important;
}
.ntmods-inline-icon {
width: 16px;
height: 16px;
display: inline-block;
vertical-align: -3px;
shape-rendering: geometricPrecision;
}
.ntmods-shell {
color: #eef3ff;
display: flex;
flex-direction: column;
}
.ntmods-shell > .well--p.well--l_p {
padding-bottom: 0 !important;
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.ntmods-cap {
display: flex;
justify-content: flex-start;
gap: 0;
align-items: center;
padding: 12px 18px;
background: url(/dist/site/images/backgrounds/bg-noise.png) top left repeat, linear-gradient(90deg, #1c99f4 0%, #167ac3 42%, #0f4f86 100%);
background-attachment: fixed, scroll;
}
.ntmods-cap-copy {
min-width: 0;
max-width: 720px;
}
.ntmods-cap-kicker {
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 11px;
font-weight: 700;
color: rgba(255, 255, 255, 0.82);
}
.ntmods-cap-title {
margin: 0;
}
.ntmods-cap-subtitle {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: rgba(255, 255, 255, 0.92);
}
.ntmods-cap-stats {
display: grid;
grid-template-columns: repeat(3, minmax(96px, 1fr));
gap: 10px;
min-width: 290px;
}
.ntmods-cap-stat {
display: flex;
flex-direction: column;
justify-content: center;
padding: 12px 14px;
border-radius: 12px;
background: rgba(10, 18, 30, 0.24);
backdrop-filter: blur(1px);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.ntmods-cap-stat-value {
font-family: "Montserrat", sans-serif;
font-size: 24px;
line-height: 1;
font-weight: 700;
color: #fff;
}
.ntmods-cap-stat-label {
margin-top: 5px;
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.76);
}
.ntmods-banner {
margin-bottom: 14px;
padding: 12px 14px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(255, 193, 7, 0.18), rgba(255, 146, 43, 0.16));
border: 1px solid rgba(255, 196, 79, 0.26);
color: #ffe8b6;
}
.ntmods-banner-title {
font-family: "Montserrat", sans-serif;
font-size: 14px;
font-weight: 700;
margin-bottom: 5px;
color: #fff0c9;
}
.ntmods-banner-copy {
font-size: 12px;
line-height: 1.55;
}
.ntmods-top-tabs {
display: flex;
flex-wrap: nowrap;
gap: 2px;
align-items: flex-end;
overflow: hidden;
margin: 0 0 0 0;
padding: 0 2px;
width: 100%;
min-width: 0;
position: relative;
z-index: 2;
}
.ntmods-top-tab {
appearance: none;
flex: var(--ntmods-tab-grow, 1) 1 0;
min-width: 0;
border: 1px solid transparent;
border-bottom: 0;
border-radius: 8px 8px 0 0;
background: transparent;
color: rgba(174, 180, 199, 0.7);
padding: 14px 14px 13px;
cursor: pointer;
position: relative;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
box-shadow: none;
}
.ntmods-top-tab:hover {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.05);
color: #eff3ff;
}
.ntmods-top-tab.is-active {
background: linear-gradient(180deg, #333752 0%, #2a2d3d 100%);
color: #fff;
border-color: rgba(255, 255, 255, 0.09);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.06);
z-index: 3;
}
.ntmods-top-tab.is-active::after {
content: "";
position: absolute;
left: -1px;
right: -1px;
bottom: -1px;
height: 3px;
background: #2a2d3d;
}
.ntmods-top-tab .bucket {
width: 100%;
gap: 6px;
}
.ntmods-top-tab .bucket-content {
min-width: 0;
font-family: "Montserrat", sans-serif;
font-size: 13px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ntmods-top-tab-glyph {
display: inline-flex;
align-items: center;
justify-content: center;
color: #b8c4da;
}
.ntmods-top-tab.is-active .ntmods-top-tab-glyph {
color: #edf3ff;
}
.ntmods-mod-icon {
display: block;
width: 100%;
height: 100%;
}
.ntmods-mod-icon--tab {
width: 15px;
height: 15px;
}
.ntmods-mod-icon--chip {
width: 20px;
height: 20px;
}
.ntmods-module-card {
display: flex;
flex-direction: column;
min-height: 900px;
flex: 1 1 auto;
border-radius: 0 0 18px 18px;
background: url(/dist/site/images/backgrounds/bg-noise.png) top left repeat, linear-gradient(180deg, #2a2d3d 0%, #232636 100%);
background-attachment: fixed, scroll;
border: 1px solid rgba(255, 255, 255, 0.09);
border-top: none;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.24);
overflow: hidden;
margin-top: 0;
position: relative;
z-index: 1;
}
.ntmods-module-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 18px 20px;
background: transparent;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.ntmods-module-head-copy {
min-width: 0;
}
.ntmods-module-overline {
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.09em;
font-size: 11px;
color: #8ea4d0;
}
.ntmods-module-title {
margin: 0;
font-family: "Montserrat", sans-serif;
font-size: 28px;
line-height: 1.08;
color: #fff;
}
.ntmods-module-description {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.5;
color: #adbbdb;
}
.ntmods-module-actions {
display: flex;
align-items: center;
gap: 0;
flex: 0 0 auto;
}
.ntmods-rescan-btn {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: #edf3ff;
border-radius: 10px;
padding: 10px 12px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.ntmods-rescan-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.ntmods-layout {
display: flex;
gap: 18px;
padding: 18px;
background: transparent;
}
.ntmods-sidebar {
width: 264px;
max-width: 264px;
display: flex;
flex-direction: column;
gap: 2px;
}
.ntmods-nav-btn {
box-shadow: none;
justify-content: flex-start;
width: 100%;
backface-visibility: hidden;
background: #393c50;
border: 1px solid transparent;
color: #a6aac1;
cursor: pointer;
display: inline-flex;
align-items: center;
font-family: "Montserrat", sans-serif;
font-size: 14px;
overflow: hidden;
padding: 13px 16px;
position: relative;
text-align: left;
transition: all 0.12s linear;
}
.ntmods-nav-btn:first-child {
border-radius: 5px 5px 0 0;
}
.ntmods-nav-btn:last-child {
border-radius: 0 0 5px 5px;
}
.ntmods-nav-btn:hover {
background: #585e7d;
color: #e2e3eb;
}
.ntmods-nav-btn.is-active {
background: #167ac3 !important;
color: #fff;
text-shadow: 0 2px 2px rgba(2, 2, 2, 0.25);
}
.ntmods-nav-btn-title {
font-size: 13px;
font-weight: 500;
}
.ntmods-stage {
min-width: 0;
flex: 1;
border-radius: 10px;
background: #2b2e3f;
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 18px;
}
.ntmods-stage-intro {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 18px;
}
.ntmods-stage-title {
margin: 0;
font-family: "Montserrat", sans-serif;
font-size: 22px;
line-height: 1.1;
color: #fff;
}
.ntmods-stage-subtitle {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.55;
color: #aebadc;
}
.ntmods-stage-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.ntmods-pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 7px 10px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
font-size: 11px;
letter-spacing: 0.03em;
text-transform: uppercase;
color: #c5d4f6;
}
.ntmods-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 1220px) {
.ntmods-top-tabs {
gap: 3px;
}
.ntmods-top-tab {
padding: 13px 11px 12px;
}
.ntmods-top-tab .bucket {
gap: 7px;
}
.ntmods-top-tab .bucket-content {
font-size: 12px;
}
.ntmods-mod-icon--tab {
width: 14px;
height: 14px;
}
}
.ntmods-field-card,
.ntmods-action-card,
.ntmods-note,
.ntmods-toggle-card {
box-sizing: border-box;
min-width: 0;
}
.ntmods-field-card,
.ntmods-action-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
border-radius: 14px;
background: #2e3346;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.ntmods-toggle-card {
display: flex;
justify-content: space-between;
gap: 14px;
padding: 14px;
border-radius: 14px;
background: #2e3346;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.ntmods-toggle-copy {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.ntmods-field-title {
font-size: 13px;
font-weight: 700;
color: #edf3ff;
}
.ntmods-field-help {
font-size: 12px;
line-height: 1.45;
color: #9aa8c9;
}
.ntmods-input {
width: 100%;
box-sizing: border-box;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
background: #1d2030;
color: #eef3ff;
padding: 10px 11px;
font-size: 13px;
}
.ntmods-input:focus {
outline: none;
border-color: #1c99f4;
box-shadow: 0 0 0 2px rgba(28, 153, 244, 0.22);
}
.ntmods-color-row {
display: flex;
align-items: center;
gap: 8px;
}
.ntmods-color-picker {
width: 48px;
min-width: 48px;
height: 42px;
border-radius: 10px;
background: #1d2030;
border: 1px solid rgba(255, 255, 255, 0.14);
padding: 0;
}
.ntmods-color-input {
text-transform: uppercase;
}
.ntmods-switch {
position: relative;
width: 42px;
height: 24px;
flex: 0 0 auto;
}
.ntmods-switch input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
z-index: 2;
}
.ntmods-switch-track {
display: block;
width: 100%;
height: 100%;
border-radius: 999px;
background: #5b627f;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
transition: background 0.14s ease;
}
.ntmods-switch-track::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
transition: transform 0.12s ease;
}
.ntmods-switch input:checked + .ntmods-switch-track {
background: #d62f3a;
box-shadow: 0 4px 18px rgba(214, 47, 58, 0.28);
}
.ntmods-switch input:checked + .ntmods-switch-track::after {
transform: translateX(18px);
}
.ntmods-action-btn {
appearance: none;
border: 0;
border-radius: 10px;
padding: 11px 14px;
cursor: pointer;
font-size: 13px;
font-weight: 700;
color: #fff;
}
.ntmods-action-btn--primary {
background: #167ac3;
}
.ntmods-action-btn--secondary {
background: #434965;
}
.ntmods-action-btn--danger {
background: #a5333d;
}
.ntmods-action-btn:hover {
filter: brightness(1.08);
}
.ntmods-note {
grid-column: 1 / -1;
padding: 14px;
border-radius: 14px;
font-size: 12px;
line-height: 1.55;
border: 1px solid transparent;
}
.ntmods-note--info {
background: rgba(28, 153, 244, 0.1);
border-color: rgba(28, 153, 244, 0.22);
color: #cde6ff;
}
.ntmods-note--warning {
background: rgba(255, 176, 32, 0.12);
border-color: rgba(255, 176, 32, 0.22);
color: #ffe1a6;
}
.ntmods-empty {
padding: 28px;
text-align: center;
border-radius: 16px;
background: rgba(255, 255, 255, 0.04);
color: #d2dcf5;
}
.ntmods-race-options-stage,
.ntmods-generic-stage {
min-width: 0;
}
.ntmods-ro-panel {
min-width: 0;
}
.ntcfg-panel-title {
margin: 0;
font-size: 20px;
font-family: "Montserrat", sans-serif;
color: #fff;
}
.ntcfg-panel-subtitle {
margin: 6px 0 16px;
color: #b5bad3;
font-size: 13px;
line-height: 1.45;
}
.ntcfg-fields {
display: flex;
flex-direction: column;
gap: 12px;
}
.ntcfg-field {
display: block;
}
.ntcfg-field-title {
color: #d8dcf2;
font-size: 13px;
margin-bottom: 6px;
}
.ntcfg-field-help {
margin-top: 5px;
color: #99a4c5;
font-size: 12px;
line-height: 1.35;
}
.ntcfg-field-warning {
color: #ffb8b8;
}
.ntcfg-input {
width: 100%;
box-sizing: border-box;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
background: #1d2030;
color: #eef3ff;
padding: 10px;
font-size: 13px;
}
.ntcfg-input:focus {
outline: none;
border-color: #1c99f4;
box-shadow: 0 0 0 2px rgba(28, 153, 244, 0.28);
}
.ntcfg-input.ntcfg-input-warning {
border-color: rgba(255, 90, 90, 0.85);
box-shadow: 0 0 0 2px rgba(255, 90, 90, 0.2);
}
.ntcfg-color-row {
display: flex;
gap: 8px;
}
.ntcfg-color-picker {
width: 52px;
min-width: 52px;
padding: 0;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: #1d2030;
cursor: pointer;
}
.ntcfg-color-hex {
flex: 1;
text-transform: uppercase;
}
.ntcfg-checkbox {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: #252839;
padding: 10px 12px;
}
.ntcfg-checkbox-label {
color: #d8dcf2;
font-size: 13px;
}
.ntcfg-checkbox-copy {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.ntcfg-checkbox-help {
color: #99a4c5;
font-size: 12px;
line-height: 1.35;
}
.ntcfg-checkbox-controls {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.ntcfg-checkbox-controls .ntcfg-color-picker {
width: 38px;
min-width: 38px;
height: 30px;
}
.ntcfg-checkbox-controls .ntcfg-input {
width: 84px;
padding: 6px 8px;
font-size: 12px;
}
.ntcfg-checkbox-controls select.ntcfg-input {
width: auto;
}
.ntcfg-switch {
position: relative;
width: 40px;
height: 24px;
flex: 0 0 auto;
display: inline-block;
cursor: pointer;
}
.ntcfg-switch input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
margin: 0;
z-index: 2;
}
.ntcfg-switch-track {
display: block;
width: 100%;
height: 100%;
border-radius: 999px;
background: #585e7d;
transition: background 0.15s ease;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.ntcfg-switch-track::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
transition: transform 0.1s ease;
}
.ntcfg-switch input:checked + .ntcfg-switch-track {
background: #d62f3a;
box-shadow: 0 2px 20px rgba(214, 47, 58, 0.35);
}
.ntcfg-switch input:checked + .ntcfg-switch-track::after {
transform: translateX(16px);
}
.ntcfg-action {
border: 0;
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
cursor: pointer;
color: #fff;
background: #393c50;
}
.ntcfg-action:hover {
filter: brightness(1.1);
}
.ntcfg-action.ntcfg-primary {
background: #167ac3;
}
.ntcfg-inline-action {
align-self: flex-start;
}
.ntcfg-theme-preview-label {
color: #d8dcf2;
font-size: 13px;
margin-bottom: 4px;
}
.ntcfg-theme-preview-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
margin-bottom: 12px;
}
.ntcfg-theme-preview {
width: 100%;
box-sizing: border-box;
padding: 12px;
margin-bottom: 0;
border-radius: 5px;
background: #e9eaeb;
color: #2e3141;
}
.ntcfg-theme-preview span {
font-family: "Roboto Mono", "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace;
display: inline-block;
padding: 2px;
font-size: 18px;
white-space: pre;
}
.ntcfg-theme-preview span.ntcfg-theme-char-active {
background: #1c99f4;
color: #fff;
}
.ntcfg-theme-preview span.ntcfg-theme-char-incorrect {
background: #d62f3a;
color: #fff;
}
.ntcfg-theme-preview span.ntcfg-theme-char-typed {
color: #2e3141;
opacity: 0.5;
}
.ntcfg-theme-preview span.ntcfg-theme-char-rainbow {
animation: ntcfg-preview-rainbow-text 10s infinite alternate;
-webkit-animation: ntcfg-preview-rainbow-text 10s infinite alternate;
opacity: 1;
}
@keyframes ntcfg-preview-rainbow-text {
0% { color: blue; }
10% { color: #ff005d; }
20% { color: #f0f; }
30% { color: black; }
40% { color: #7500ff; }
50% { color: blue; }
60% { color: #f0f; }
70% { color: black; }
80% { color: black; }
90% { color: red; }
100% { color: red; }
}
.ntcfg-sticky-preview {
position: sticky;
top: 0;
z-index: 10;
background: #2b2e3f;
padding-bottom: 12px;
margin-bottom: 4px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.ntcfg-pn-preview {
width: 100%;
box-sizing: border-box;
padding: 12px;
border-radius: 5px;
background: #e9eaeb;
color: #2e3141;
margin-bottom: 0;
overflow: hidden;
word-wrap: break-word;
}
.ntcfg-pn-preview span {
font-family: "Roboto Mono", "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace;
display: inline-block;
padding: 2px;
font-size: 18px;
white-space: pre;
}
.ntcfg-pn-preview-rainbow {
animation: ntcfg-pn-preview-rainbow 3s linear infinite;
-webkit-animation: ntcfg-pn-preview-rainbow 3s linear infinite;
}
@keyframes ntcfg-pn-preview-rainbow {
0% { color: #FF0000; }
14% { color: #FF8C00; }
28% { color: #FFD700; }
42% { color: #00CC00; }
57% { color: #0066FF; }
71% { color: #7B00FF; }
85% { color: #FF00FF; }
100% { color: #FF0000; }
}
.ntcfg-theme-settings {
display: flex;
flex-direction: column;
gap: 8px;
}
.ntcfg-theme-setting {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: #252839;
padding: 8px 10px;
}
.ntcfg-theme-setting-title {
color: #d8dcf2;
font-size: 13px;
}
.ntcfg-theme-setting-controls {
display: flex;
align-items: center;
gap: 8px;
}
.ntcfg-theme-color-compact {
display: flex;
align-items: center;
gap: 6px;
}
.ntcfg-theme-color-compact .ntcfg-color-picker {
width: 38px;
min-width: 38px;
height: 30px;
}
.ntcfg-theme-color-compact .ntcfg-input {
width: 96px;
padding: 6px 8px;
font-size: 12px;
}
.ntcfg-theme-input-compact {
width: auto;
padding: 6px 8px;
font-size: 12px;
}
.ntcfg-theme-input-select {
width: 180px;
}
.ntcfg-theme-input-number {
width: 96px;
}
.ntcfg-theme-input-preset {
width: 132px;
}
@media (min-width: 900px) {
.ntcfg-theme-preview-grid {
grid-template-columns: 1fr 1fr;
}
}
.ntmods-toast {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 100002;
padding: 12px 14px;
border-radius: 12px;
background: rgba(10, 16, 26, 0.96);
color: #eff5ff;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.38);
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: opacity 0.16s ease, transform 0.16s ease;
font-size: 12px;
line-height: 1.35;
}
.ntmods-toast.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 1100px) {
.ntmods-cap {
flex-direction: column;
}
.ntmods-cap-stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
min-width: 0;
}
.ntmods-module-card {
min-height: 820px;
}
}
@media (max-width: 980px) {
.ntmods-layout {
flex-direction: column;
}
.ntmods-sidebar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: auto;
max-width: none;
gap: 8px;
}
.ntmods-stage-intro {
flex-direction: column;
}
.ntmods-grid {
grid-template-columns: 1fr;
}
.ntmods-nav-btn,
.ntmods-nav-btn:first-child,
.ntmods-nav-btn:last-child {
border-radius: 8px;
}
}
@media (max-width: 720px) {
.ntmods-cap {
padding: 18px;
}
.ntmods-cap-stats {
grid-template-columns: 1fr;
}
.ntmods-module-head {
flex-direction: column;
align-items: stretch;
}
.ntmods-module-actions {
justify-content: space-between;
}
.ntmods-sidebar {
grid-template-columns: 1fr;
}
.ntmods-module-card {
min-height: 700px;
}
.ntmods-top-tab {
min-width: 0;
padding: 12px 8px 11px;
}
.ntmods-top-tab .bucket-content {
font-size: 11px;
}
.ntmods-top-tab-glyph {
}
.ntmods-mod-icon--tab {
width: 13px;
height: 13px;
}
}
/* Drag-to-reorder tabs */
.ntmods-tab-dragging {
opacity: 0.4;
outline: 2px dashed rgba(255,255,255,0.2);
}
.ntmods-tab-dragover {
background: rgba(28, 153, 244, 0.15) !important;
border-color: rgba(28, 153, 244, 0.4) !important;
}
.ntmods-top-tab[draggable="true"] {
cursor: grab;
}
.ntmods-top-tab[draggable="true"]:active {
cursor: grabbing;
}
/* Not Installed card */
.ntmods-not-installed {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 60px 30px;
gap: 12px;
min-height: 300px;
}
.ntmods-not-installed-icon {
opacity: 0.3;
margin-bottom: 8px;
}
.ntmods-not-installed-icon svg {
width: 64px;
height: 64px;
}
.ntmods-not-installed-title {
font-family: "Montserrat", sans-serif;
font-size: 22px;
font-weight: 700;
color: rgba(174, 180, 199, 0.8);
margin: 0;
}
.ntmods-not-installed-copy {
font-size: 14px;
color: rgba(174, 180, 199, 0.6);
max-width: 420px;
line-height: 1.5;
margin: 0;
}
.ntmods-not-installed-copy strong {
color: rgba(220, 225, 240, 0.8);
}
.ntmods-install-link {
display: inline-block;
margin-top: 12px;
padding: 10px 24px;
font-family: "Montserrat", sans-serif;
font-size: 13px;
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, #1c99f4 0%, #1a7ed4 100%);
border-radius: 6px;
text-decoration: none;
transition: background 0.15s ease, transform 0.1s ease;
}
.ntmods-install-link:hover {
background: linear-gradient(135deg, #2ba6ff 0%, #1c99f4 100%);
transform: translateY(-1px);
}
/* Preview tab styling (greyed out) */
.ntmods-top-tab.is-preview {
opacity: 0.5;
}
.ntmods-top-tab.is-preview:hover {
opacity: 0.75;
}
`;
(document.head || document.documentElement).appendChild(style);
}
function setActiveDropdownItem() {
document.querySelectorAll(`.${DROPDOWN_ITEM_CLASS}`).forEach((item) => {
item.classList.toggle('is-current', isModMenuRoute());
});
}
function setPageTitle() {
if (isModMenuRoute()) {
document.title = 'Mods | Nitro Type';
}
}
function ensureEntryStyles() {
if (document.getElementById('ntmods-entry-style')) return;
const style = document.createElement('style');
style.id = 'ntmods-entry-style';
style.textContent = `
.ntmods-dropdown-item.is-current .dropdown-link {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.ntmods-dropdown-item .dropdown-link .icon {
opacity: 0.92;
}
.ntmods-dropdown-item .dropdown-link .ntmods-inline-icon {
opacity: 1;
color: #9fb2c6 !important;
fill: currentColor !important;
}
.ntmods-dropdown-item .dropdown-link:hover .ntmods-inline-icon,
.ntmods-dropdown-item.is-current .dropdown-link .ntmods-inline-icon {
color: #c8d5e2 !important;
fill: currentColor !important;
}
.ntmods-inline-icon {
width: 16px;
height: 16px;
min-width: 16px;
min-height: 16px;
display: inline-block;
vertical-align: -3px;
flex: 0 0 16px;
shape-rendering: geometricPrecision;
}
`;
(document.head || document.documentElement).appendChild(style);
}
function getModsMenuIconMarkup() {
return `
<svg class="ntmods-inline-icon mrxs" viewBox="0 0 177.5 178" aria-hidden="true">
<g
transform="translate(-12,189) scale(0.1,-0.1)"
fill="currentColor"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
stroke-width="102"
d="M867 1870 c-18 -15 -28 -37 -38 -86 -8 -42 -19 -68 -29 -71 -8 -3
-49 -20 -91 -37 l-76 -32 -34 24 c-59 41 -95 53 -126 41 -15 -5 -61 -42 -100
-82 -94 -93 -100 -116 -48 -194 35 -53 37 -59 25 -88 -7 -16 -22 -57 -35 -90
l-23 -60 -65 -15 c-103 -24 -107 -30 -107 -177 0 -155 4 -160 133 -187 97 -21
111 -17 105 30 -3 27 -8 30 -68 44 -36 8 -71 19 -77 23 -9 5 -13 35 -13 87 0
52 4 82 13 87 6 4 41 15 77 23 l64 15 26 75 c14 41 35 92 48 113 30 51 28 68
-13 133 -19 30 -35 58 -35 62 0 4 27 34 59 66 l60 59 38 -25 c90 -59 96 -60
150 -31 26 14 82 38 123 53 l75 26 16 75 15 74 86 3 c99 3 97 5 117 -90 14
-64 19 -70 81 -87 25 -7 75 -29 113 -47 75 -38 72 -38 156 19 l43 30 64 -64
64 -64 -42 -63 -41 -63 41 -87 c23 -48 44 -100 48 -117 10 -45 20 -53 82 -66
92 -18 92 -18 92 -109 0 -91 0 -91 -92 -109 -65 -14 -72 -20 -88 -81 -6 -25
-27 -74 -46 -110 -19 -35 -34 -69 -34 -75 0 -5 18 -37 40 -70 l40 -60 -62 -62
c-34 -34 -64 -61 -67 -59 -3 2 -34 20 -69 41 l-64 39 -56 -31 c-31 -17 -87
-41 -123 -53 l-66 -21 -19 -77 -19 -77 -78 -3 c-46 -2 -84 2 -92 8 -7 6 -18
38 -25 73 -13 69 -24 87 -50 87 -35 0 -44 -26 -32 -93 29 -155 32 -157 196
-157 140 0 150 6 172 103 13 57 20 70 42 79 15 6 55 23 89 39 l61 27 54 -36
c84 -57 102 -52 203 48 102 101 107 123 51 206 l-35 52 36 83 c19 46 42 89 50
96 8 6 37 14 64 18 27 4 59 15 72 25 21 17 22 26 22 150 0 154 -3 159 -105
179 -51 10 -63 16 -72 39 -6 15 -24 55 -39 90 l-29 63 35 52 c55 81 50 111
-30 196 -72 75 -123 106 -158 95 -12 -4 -44 -22 -70 -39 l-49 -33 -61 27 c-34
16 -74 33 -89 39 -22 9 -29 22 -42 79 -22 98 -31 103 -176 103 -102 0 -121 -3
-143 -20z"
/>
</g>
<svg x="7" y="24" width="156" height="156" viewBox="0 0 256 256">
<g transform="translate(0,256) scale(0.1,-0.1)" fill="currentColor" stroke="none">
<path d="M1162 1810 c-45 -11 -105 -31 -133 -46 -78 -43 -154 -125 -197 -212
l-37 -77 0 -175 0 -176 -230 -209 c-376 -342 -471 -439 -495 -502 -69 -186 63
-377 261 -376 118 0 170 41 469 368 258 283 366 389 387 380 32 -13 157 -24
208 -19 61 6 147 31 197 55 71 36 162 133 204 216 l39 78 0 135 c-1 119 -4
143 -25 200 -30 78 -61 100 -108 76 -16 -8 -82 -66 -147 -128 -128 -123 -160
-148 -189 -148 -27 0 -85 47 -97 78 -13 33 1 52 146 206 134 143 150 167 135
195 -16 31 -80 60 -178 81 -104 23 -105 23 -210 0z"/>
</g>
</svg>
</svg>
`;
}
function insertModsDropdownItem() {
ensureEntryStyles();
const dropdownLists = document.querySelectorAll('.dropdown--account .dropdown-items');
if (!dropdownLists.length) return;
dropdownLists.forEach((list) => {
if (list.querySelector(`.${DROPDOWN_ITEM_CLASS}`)) return;
const li = document.createElement('li');
li.className = `list-item dropdown-item ${DROPDOWN_ITEM_CLASS}`;
li.innerHTML = `
<a class="dropdown-link" href="${MOD_MENU_PATH}">
${getModsMenuIconMarkup()}
Mods
</a>
`;
const statsItem = Array.from(list.children).find((item) => {
const link = item.querySelector('a[href="/stats"]');
return !!link || item.textContent.trim().includes('My Stats');
});
if (statsItem) {
statsItem.after(li);
} else {
list.appendChild(li);
}
});
setActiveDropdownItem();
}
function updateRouteStatus() {
const main = document.querySelector('main.structure-content');
if (isModMenuRoute()) {
document.documentElement.classList.add('is-mods-route');
} else {
document.documentElement.classList.remove('is-mods-route');
if (main) main.classList.remove('custom-loaded');
}
}
function getModuleById(modules, moduleId) {
return modules.find((module) => module.id === moduleId) || modules[0] || null;
}
function renderPage(requestedModuleId = '', requestedSectionId = '') {
if (renderInProgress || !isModMenuRoute()) return;
const mainContent = document.querySelector('main.structure-content');
if (!mainContent) return;
renderInProgress = true;
try {
ensureStyles();
const modules = buildVisibleModules();
const selection = requestedModuleId
? {
activeModuleId: requestedModuleId,
activeSectionId: requestedSectionId
}
: getInitialSelection(modules);
const activeModule = getModuleById(modules, selection.activeModuleId);
const activeModuleSections = getRenderableSections(activeModule);
const activeSection = activeModule
? activeModuleSections.find((section) => section.id === selection.activeSectionId) || activeModuleSections[0]
: null;
const nextUiState = {
moduleId: activeModule ? activeModule.id : '',
sectionId: activeSection ? activeSection.id : ''
};
writeUiState(nextUiState);
mainContent.innerHTML = buildPageHtml(modules, nextUiState.moduleId, nextUiState.sectionId);
requestAnimationFrame(() => {
mainContent.classList.add('custom-loaded');
});
bindPageEvents(modules);
setActiveDropdownItem();
setPageTitle();
} finally {
renderInProgress = false;
}
}
function bindPageEvents(modules) {
document.querySelectorAll('[data-module-id]').forEach((button) => {
button.addEventListener('click', () => {
const moduleId = button.getAttribute('data-module-id') || '';
const module = getModuleById(modules, moduleId);
const moduleSections = getRenderableSections(module);
const sectionId = moduleSections[0] ? moduleSections[0].id : '';
renderPage(moduleId, sectionId);
});
});
// ── Drag-to-reorder tabs ──
const tabBar = document.querySelector('.ntmods-top-tabs');
if (tabBar) {
let dragSrc = null;
let dragPlaceholder = null;
tabBar.querySelectorAll('[data-module-id]').forEach((tab) => {
tab.draggable = true;
tab.addEventListener('dragstart', (e) => {
dragSrc = tab;
tab.classList.add('ntmods-tab-dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', tab.getAttribute('data-module-id'));
});
tab.addEventListener('dragend', () => {
if (dragSrc) dragSrc.classList.remove('ntmods-tab-dragging');
if (dragPlaceholder) {
dragPlaceholder.remove();
dragPlaceholder = null;
}
tabBar.querySelectorAll('.ntmods-tab-dragover').forEach((el) => el.classList.remove('ntmods-tab-dragover'));
dragSrc = null;
});
tab.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (tab === dragSrc) return;
tab.classList.add('ntmods-tab-dragover');
});
tab.addEventListener('dragleave', () => {
tab.classList.remove('ntmods-tab-dragover');
});
tab.addEventListener('drop', (e) => {
e.preventDefault();
tab.classList.remove('ntmods-tab-dragover');
if (!dragSrc || dragSrc === tab) return;
// Reorder in DOM
const allTabs = [...tabBar.querySelectorAll('[data-module-id]')];
const fromIdx = allTabs.indexOf(dragSrc);
const toIdx = allTabs.indexOf(tab);
if (fromIdx < toIdx) {
tab.parentNode.insertBefore(dragSrc, tab.nextSibling);
} else {
tab.parentNode.insertBefore(dragSrc, tab);
}
// Persist new order
const newOrder = [...tabBar.querySelectorAll('[data-module-id]')].map(
(t) => t.getAttribute('data-module-id')
);
try {
localStorage.setItem('ntmods:tab-order', JSON.stringify(newOrder));
} catch { /* ignore */ }
showToast('Tab order saved.');
});
});
}
document.querySelectorAll('[data-section-id]').forEach((button) => {
button.addEventListener('click', () => {
const uiState = readUiState();
renderPage(uiState.moduleId || '', button.getAttribute('data-section-id') || '');
});
});
const uiState = readUiState();
const activeModule = getModuleById(modules, uiState.moduleId);
const activeModuleSections = getRenderableSections(activeModule);
const activeSection = activeModule
? activeModuleSections.find((section) => section.id === uiState.sectionId) || activeModuleSections[0]
: null;
document.querySelectorAll('[data-action-key]').forEach((button) => {
button.addEventListener('click', () => {
const actionKey = button.getAttribute('data-action-key') || '';
if (actionKey === 'rescan') {
showToast('Rescanning installed mods...');
renderPage();
return;
}
showToast('Preview action only for now.');
});
});
if (activeModule?.source === 'manifest' && activeSection) {
mountGenericSection(activeModule, activeSection);
return;
}
const fieldMap = new Map((activeSection?.items || []).filter((item) => item.key).map((item) => [item.key, item]));
document.querySelectorAll('[data-field-key]').forEach((input) => {
const fieldKey = input.getAttribute('data-field-key') || '';
const item = fieldMap.get(fieldKey);
if (!activeModule || !item) return;
const inputType = input.getAttribute('data-field-type') || item.type;
const eventName = item.type === 'toggle' || inputType === 'select' ? 'change' : 'input';
input.addEventListener(eventName, () => {
if (inputType === 'color-text') {
const colorValue = String(input.value || '').trim();
const colorPicker = document.querySelector(`[data-field-key="${CSS.escape(fieldKey)}"][data-field-type="color"]`);
if (/^#[0-9A-Fa-f]{6}$/.test(colorValue)) {
setFieldValue(activeModule, item, colorValue.toUpperCase());
if (colorPicker) colorPicker.value = colorValue.toUpperCase();
}
return;
}
const nextValue = coerceInputValue(input, item);
setFieldValue(activeModule, item, nextValue);
if (inputType === 'color') {
const colorText = document.querySelector(`[data-field-key="${CSS.escape(fieldKey)}"][data-field-type="color-text"]`);
if (colorText) colorText.value = String(nextValue).toUpperCase();
}
});
if (inputType === 'text' || inputType === 'number' || inputType === 'select') {
input.addEventListener('change', () => {
const nextValue = coerceInputValue(input, item);
setFieldValue(activeModule, item, nextValue);
});
}
});
}
function handlePage() {
insertModsDropdownItem();
if (!isModMenuRoute()) {
setActiveDropdownItem();
return;
}
const main = document.querySelector('main.structure-content');
if (!main) return;
const app = document.getElementById('ntmods-app');
if (app) {
setActiveDropdownItem();
setPageTitle();
return;
}
const text = main.textContent || '';
const shouldRender = main.children.length === 0
|| !!main.querySelector('.error')
|| text.includes('Page Not Found')
|| text.includes('page not found');
if (shouldRender) {
renderPage();
}
}
function scheduleHandlePage() {
if (handleScheduled) return;
handleScheduled = true;
requestAnimationFrame(() => {
handleScheduled = false;
handlePage();
});
}
function refreshActiveModMenuPage() {
if (!isModMenuRoute()) return;
if (document.getElementById('ntmods-app')) {
renderPage();
return;
}
scheduleHandlePage();
}
function fastInject() {
insertModsDropdownItem();
if (!isModMenuRoute()) return;
const waitForMain = new MutationObserver(() => {
const main = document.querySelector('main.structure-content');
if (!main) return;
const text = main.textContent || '';
if (main.children.length === 0 || !!main.querySelector('.error') || text.includes('Page Not Found')) {
renderPage();
waitForMain.disconnect();
}
});
waitForMain.observe(document.documentElement, { childList: true, subtree: true });
}
updateRouteStatus();
const originalPushState = history.pushState;
history.pushState = function () {
const result = originalPushState.apply(this, arguments);
updateRouteStatus();
scheduleHandlePage();
return result;
};
const originalReplaceState = history.replaceState;
history.replaceState = function () {
const result = originalReplaceState.apply(this, arguments);
updateRouteStatus();
scheduleHandlePage();
return result;
};
window.addEventListener('popstate', () => {
updateRouteStatus();
scheduleHandlePage();
});
document.addEventListener('ntcfg:manifest-updated', refreshActiveModMenuPage);
window.addEventListener('storage', (event) => {
const key = String(event?.key || '');
if (!key) return;
if (key.startsWith(MANIFEST_PREFIX) || key.startsWith('ntcfg:')) {
refreshActiveModMenuPage();
}
});
fastInject();
const navObserver = new MutationObserver(() => {
scheduleHandlePage();
});
navObserver.observe(document.documentElement, { childList: true, subtree: true });
})();