GPT is about to go nuts with my project.

This commit is contained in:
2026-03-13 17:21:38 -04:00
parent 591e4155fc
commit bce93507cb
4 changed files with 712 additions and 255 deletions

View File

@@ -1275,50 +1275,94 @@ function exportQuoteJSON() {
}
// ── THEME TOGGLE ─────────────────────────────────────────────────
// Light theme is a separate CSS file imported dynamically on demand.
// Dark mode = base stylesheet only (no extra link element).
// Light mode = base + SVS-MSP-Calculator-light.css.
// Base CSS is the default dark theme.
// Variant themes are imported dynamically on demand:
// light = base + SVS-MSP-Calculator-light.css
// glass = base + SVS-MSP-Calculator-glass.css
// Preference persisted to localStorage under 'svs-theme'.
// initTheme() called first so the page never flashes the wrong theme.
// initTheme() runs before initQuote() so the UI boots in the saved theme.
const THEME_STORAGE_KEY = 'svs-theme';
const THEME_STYLESHEET_ID = 'themeStylesheetLink';
const THEME_ASSET_VERSION = '20260313-02';
const SVG_SUN = '<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
const SVG_MOON = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
const SVG_GLASS = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l2.7 5.3L20 11l-5.3 2.7L12 19l-2.7-5.3L4 11l5.3-2.7L12 3z"/><path d="M18.5 3.5l.8 1.7L21 6l-1.7.8-.8 1.7-.8-1.7L16 6l1.7-.8.8-1.7z"/></svg>';
const THEME_ORDER = ['dark', 'light', 'glass'];
const THEME_CONFIG = {
dark: {
icon: SVG_MOON,
href: null,
label: 'Dark'
},
light: {
icon: SVG_SUN,
href: 'SVS-MSP-Calculator-light.css',
label: 'Light'
},
glass: {
icon: SVG_GLASS,
href: 'SVS-MSP-Calculator-glass.css',
label: 'Glass'
}
};
function toggleTheme() {
const link = document.getElementById('lightThemeLink');
function getSavedTheme() {
const saved = localStorage.getItem(THEME_STORAGE_KEY);
return THEME_ORDER.includes(saved) ? saved : 'dark';
}
function getCurrentTheme() {
const applied = document.documentElement.dataset.theme;
return THEME_ORDER.includes(applied) ? applied : getSavedTheme();
}
function updateThemeToggleUi(theme) {
const icon = document.getElementById('themeToggleIcon');
const btn = document.getElementById('themeToggle');
if (link) {
// Currently light → switch to dark
link.remove();
localStorage.setItem('svs-theme', 'dark');
if (icon) icon.innerHTML = SVG_SUN;
if (btn) btn.setAttribute('title', 'Switch to light theme');
} else {
// Currently dark → switch to light
const el = document.createElement('link');
el.id = 'lightThemeLink';
el.rel = 'stylesheet';
el.href = 'SVS-MSP-Calculator-light.css';
document.head.appendChild(el);
localStorage.setItem('svs-theme', 'light');
if (icon) icon.innerHTML = SVG_MOON;
if (btn) btn.setAttribute('title', 'Switch to dark theme');
const btn = document.getElementById('themeToggle');
const currentIndex = THEME_ORDER.indexOf(theme);
const nextTheme = THEME_ORDER[(currentIndex + 1) % THEME_ORDER.length];
const currentLabel = THEME_CONFIG[theme].label;
const nextLabel = THEME_CONFIG[nextTheme].label;
if (icon) icon.innerHTML = THEME_CONFIG[theme].icon;
if (btn) {
const uiLabel = `Theme: ${currentLabel}. Click to switch to ${nextLabel}.`;
btn.setAttribute('title', uiLabel);
btn.setAttribute('aria-label', uiLabel);
}
}
function initTheme() {
if (localStorage.getItem('svs-theme') === 'light') {
function applyTheme(theme) {
const nextTheme = THEME_ORDER.includes(theme) ? theme : 'dark';
const existing = document.getElementById(THEME_STYLESHEET_ID);
const legacyLight = document.getElementById('lightThemeLink');
if (existing) existing.remove();
if (legacyLight) legacyLight.remove();
const themeHref = THEME_CONFIG[nextTheme].href;
if (themeHref) {
const el = document.createElement('link');
el.id = 'lightThemeLink';
el.rel = 'stylesheet';
el.href = 'SVS-MSP-Calculator-light.css';
el.id = THEME_STYLESHEET_ID;
el.rel = 'stylesheet';
el.href = `${themeHref}?v=${THEME_ASSET_VERSION}`;
el.dataset.theme = nextTheme;
document.head.appendChild(el);
const icon = document.getElementById('themeToggleIcon');
const btn = document.getElementById('themeToggle');
if (icon) icon.innerHTML = SVG_MOON;
if (btn) btn.setAttribute('title', 'Switch to dark theme');
}
document.documentElement.dataset.theme = nextTheme;
localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
updateThemeToggleUi(nextTheme);
}
function toggleTheme() {
const currentTheme = getCurrentTheme();
const currentIndex = THEME_ORDER.indexOf(currentTheme);
const nextTheme = THEME_ORDER[(currentIndex + 1) % THEME_ORDER.length];
applyTheme(nextTheme);
}
function initTheme() {
applyTheme(getSavedTheme());
}
// ── initQuote() ──────────────────────────────────────────────────
@@ -1361,33 +1405,54 @@ initTheme();
initQuote();
// ── MOBILE SIDEBAR SYNC CONTRACT ──────────────────────────────────────
// Every stateful sidebar element has a mirror ID with _m suffix.
// The update() wrapper below syncs _m elements after each call to _origUpdate().
// The desktop sidebar is the single markup source of truth.
// On boot, we clone it into #mobilePanelContent and suffix all IDs with _m.
// The update() wrapper below then syncs dynamic values/classes into the clone.
//
// WHEN ADDING A NEW SIDEBAR ELEMENT:
// 1. Add the desktop element with its ID (e.g. #my-element)
// 2. Add the mobile duplicate in #mobilePanelContent with ID #my-element_m
// 3. Add the appropriate sync call in the update() wrapper:
// syncEl(id) — copies innerHTML (text/HTML values)
// syncClass(id) — copies className (.hidden toggling via classList)
// syncStyle(id) — copies style.cssText (legacy inline display — avoid for new elements)
// syncChecked(id) — copies .checked state (checkboxes)
//
// NEVER DOM-move the real .sidebar into the panel.
// The duplicate HTML is intentional — moving breaks desktop layout on resize.
// 2. Ensure the mobile sync map below includes it if it changes at runtime
// 3. Avoid separate handwritten mobile markup for sidebar content
// ──────────────────────────────────────────────────────────────────────
// ── MOBILE QUOTE PANEL IIFE ──────────────────────────────────────
// Encapsulates all mobile panel logic to avoid polluting global scope.
// ARCHITECTURE:
// The real sidebar lives in .side-col (desktop).
// The panel contains a STATIC DUPLICATE with _m suffixed IDs.
// The mobile panel gets a JS-generated clone with _m suffixed IDs.
// update() is wrapped here to sync _m elements after every update.
// openMobilePanel / closeMobilePanel are exposed on window.
// Do NOT DOM-move the real sidebar into the panel —
// Do NOT DOM-move the real desktop sidebar into the panel —
// it permanently breaks desktop layout on resize.
(function() {
// Panel uses a static duplicate sidebar (_m IDs) — no DOM moving needed.
function buildMobileSidebar() {
var container = document.getElementById('mobilePanelContent');
var desktopSidebar = document.querySelector('.side-col .sidebar');
if (!container || !desktopSidebar || container.children.length) return;
var mobileSidebar = desktopSidebar.cloneNode(true);
mobileSidebar.removeAttribute('id');
mobileSidebar.querySelectorAll('[id]').forEach(function(el) {
el.id = el.id + '_m';
});
var mobileHstToggle = mobileSidebar.querySelector('#hstToggle_m');
if (mobileHstToggle) {
mobileHstToggle.onchange = function() {
var desktopHstToggle = document.getElementById('hstToggle');
if (desktopHstToggle) desktopHstToggle.checked = this.checked;
update();
};
}
var mobileExportJson = mobileSidebar.querySelector('#btnExportJSON_m');
if (mobileExportJson) mobileExportJson.removeAttribute('id');
container.appendChild(mobileSidebar);
}
buildMobileSidebar();
window.openMobilePanel = function() {
var panel = document.getElementById('mobileQuotePanel');
@@ -1437,6 +1502,84 @@ initQuote();
if (src && dst) dst.checked = src.checked;
}
var sidebarSyncMap = {
html: [
'clientNameDisplay',
'sl-users-val',
'sl-endpoints-val',
'sl-servers-val',
'sl-zt-val',
'sl-voip-val',
'sl-admin-val',
'mrrDisplay',
'annualDisplay',
'perUserDisplay',
'perUserBreakdown',
'm365SaveAmt',
'sl-discount-val',
'sl-base-mrr-val',
'sl-hst-val',
'sl-hst-total-val',
'sl-otf-val',
'vs-svs-annual',
'vs-1man-cost',
'vs-1man-save',
'vs-1man-save-lbl',
'vs-5man-cost',
'vs-5man-save',
'vs-5man-save-lbl',
'vs-footnote',
'nudgeText',
'nudgeCounter',
'sl-users-sub',
'sl-endpoints-sub',
'sl-admin-sub',
'adminWaivedAmt'
],
class: [
'sl-users',
'sl-users-sub',
'sl-endpoints',
'sl-endpoints-sub',
'sl-admin-sub',
'sl-servers',
'sl-zt',
'sl-voip',
'sl-admin',
'sideNote-m365',
'sideNote-byol',
'vsComparison',
'perUserRow',
'perUserBreakdown',
'sl-discount-row',
'sl-base-mrr-row',
'sl-hst-row',
'sl-hst-total-row',
'sl-otf-row',
'vs-1man-save-row',
'vs-1man-save',
'vs-1man-save-lbl',
'vs-5man-save-row',
'vs-5man-save',
'vs-5man-save-lbl',
'nudgeBanner',
'adminWaivedSavings'
],
style: [
'sl-users-sub',
'sl-endpoints-sub',
'sl-admin-sub',
'perUserRow'
],
checked: [
'hstToggle'
]
};
function runSidebarSync(ids, syncFn) {
ids.forEach(syncFn);
}
// ── UPDATE WRAPPER ─────────────────────────────────────────────
// Wraps the global update() to also sync the mobile panel.
// _origUpdate = the real update() defined above.
@@ -1445,70 +1588,10 @@ initQuote();
var _origUpdate = window.update;
window.update = function() {
_origUpdate();
// Sync all mirrored sidebar elements
syncEl('clientNameDisplay');
syncEl('sl-users-val');
syncEl('sl-endpoints-val');
syncEl('sl-servers-val');
syncEl('sl-zt-val');
syncEl('sl-voip-val');
syncEl('sl-admin-val');
syncEl('mrrDisplay');
syncEl('annualDisplay');
syncEl('perUserDisplay');
syncEl('perUserBreakdown');
syncEl('m365SaveAmt');
syncEl('sl-discount-val');
syncEl('sl-base-mrr-val');
syncEl('sl-hst-val');
syncEl('sl-hst-total-val');
syncEl('sl-otf-val');
syncEl('vs-svs-annual');
syncEl('vs-1man-cost');
syncEl('vs-1man-save');
syncEl('vs-1man-save-lbl');
syncEl('vs-5man-cost');
syncEl('vs-5man-save');
syncEl('vs-5man-save-lbl');
syncEl('vs-footnote');
syncEl('nudgeText');
syncEl('nudgeCounter');
syncEl('sl-users-sub');
syncEl('sl-endpoints-sub');
syncEl('sl-admin-sub');
syncClass('sl-users');
syncClass('sl-users-sub');
syncClass('sl-endpoints');
syncClass('sl-endpoints-sub');
syncClass('sl-admin-sub');
syncClass('sl-servers');
syncClass('sl-zt');
syncClass('sl-voip');
syncClass('sl-admin');
syncClass('sideNote-m365');
syncClass('sideNote-byol');
syncClass('vsComparison');
syncClass('perUserRow');
syncClass('perUserBreakdown');
syncClass('sl-discount-row');
syncClass('sl-base-mrr-row');
syncClass('sl-hst-row');
syncClass('sl-hst-total-row');
syncClass('sl-otf-row');
syncClass('vs-1man-save-row');
syncClass('vs-1man-save');
syncClass('vs-1man-save-lbl');
syncClass('vs-5man-save-row');
syncClass('vs-5man-save');
syncClass('vs-5man-save-lbl');
syncClass('nudgeBanner');
syncClass('adminWaivedSavings');
syncEl('adminWaivedAmt');
syncStyle('sl-users-sub');
syncStyle('sl-endpoints-sub');
syncStyle('sl-admin-sub');
syncStyle('perUserRow');
syncChecked('hstToggle');
runSidebarSync(sidebarSyncMap.html, syncEl);
runSidebarSync(sidebarSyncMap.class, syncClass);
runSidebarSync(sidebarSyncMap.style, syncStyle);
runSidebarSync(sidebarSyncMap.checked, syncChecked);
// Pill MRR — show effective MRR with label
var mrr = document.getElementById('mrrDisplay');
var pill = document.getElementById('mobilePillMrr');