diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 1f535a78..1fb6800d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -44,6 +44,26 @@ jobs:
- name: Run tests
run: python -m pytest tests/ -v --tb=short --ignore=tests/e2e
+ test-js:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Set up Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: package-lock.json
+
+ - name: Install JavaScript dependencies
+ run: npm ci
+
+ - name: Run JavaScript tests
+ run: npm test
+
test-windows:
runs-on: windows-latest
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1193dbf5..4570d478 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -55,9 +55,11 @@ This runs on port **8767** (`http://localhost:8767`) in demo mode. Production us
```bash
python -m pytest tests/ -v
+npm ci
+npm test
```
-The test suite covers analyzers, collectors, drivers, event detection, API endpoints, config, MQTT, i18n, and PDF generation. All tests must pass before submitting a PR.
+The Python suite covers analyzers, collectors, drivers, event detection, API endpoints, config, MQTT, i18n, and PDF generation. The zero-dependency JavaScript lane uses Node 22's built-in test runner for browser bootstrap and pure frontend contracts. Run both suites before submitting a PR.
## Running Locally
diff --git a/app/modules/connection_monitor/static/js/connection-monitor-settings.js b/app/modules/connection_monitor/static/js/connection-monitor-settings.js
new file mode 100644
index 00000000..556f6bdd
--- /dev/null
+++ b/app/modules/connection_monitor/static/js/connection-monitor-settings.js
@@ -0,0 +1,142 @@
+(function () {
+ var listEl = document.getElementById('cm-targets-list');
+ var addBtn = document.getElementById('cm-add-target-btn');
+ var bootstrapElement = document.getElementById('docsight-connection-monitor-settings-bootstrap');
+ var i18n = DOCSightBrowserContracts.parseConnectionMonitorBootstrapText(
+ bootstrapElement && bootstrapElement.textContent
+ );
+
+ function makeInput(id, value, placeholder) {
+ var el = document.createElement('input');
+ el.className = 'form-input';
+ el.type = 'text';
+ el.id = id;
+ el.value = value || '';
+ el.placeholder = placeholder || '';
+ return el;
+ }
+
+ function makeLabel(forId, text) {
+ var el = document.createElement('label');
+ el.className = 'form-label';
+ el.htmlFor = forId;
+ el.textContent = text;
+ return el;
+ }
+
+ function renderTarget(target) {
+ var row = document.createElement('div');
+ row.className = 'form-grid cols-2';
+ row.style.cssText = 'align-items: end; margin-bottom: var(--space-sm);';
+ row.dataset.targetId = target.id;
+
+ // Label field
+ var labelField = document.createElement('div');
+ labelField.className = 'form-field';
+ var labelInput = makeInput('cm-label-' + target.id, target.label, 'Gateway');
+ labelInput.dataset.field = 'label';
+ labelField.appendChild(makeLabel('cm-label-' + target.id, i18n.label));
+ labelField.appendChild(labelInput);
+
+ // Host field + remove button wrapper
+ var hostField = document.createElement('div');
+ hostField.className = 'form-field';
+ hostField.style.cssText = 'display: flex; gap: var(--space-sm); align-items: flex-end;';
+
+ var hostWrap = document.createElement('div');
+ hostWrap.style.flex = '1';
+ var hostInput = makeInput('cm-host-' + target.id, target.host, '8.8.8.8');
+ hostInput.dataset.field = 'host';
+ hostWrap.appendChild(makeLabel('cm-host-' + target.id, i18n.host));
+ hostWrap.appendChild(hostInput);
+
+ var removeBtn = document.createElement('button');
+ removeBtn.type = 'button';
+ removeBtn.className = 'btn btn-ghost btn-sm';
+ removeBtn.style.flexShrink = '0';
+ removeBtn.dataset.remove = target.id;
+
+ var trashIcon = document.createElement('i');
+ trashIcon.setAttribute('data-lucide', 'trash-2');
+ trashIcon.style.cssText = 'width:14px;height:14px;';
+ var removeText = document.createElement('span');
+ removeText.style.marginLeft = '4px';
+ removeText.textContent = i18n.remove;
+ removeBtn.appendChild(trashIcon);
+ removeBtn.appendChild(removeText);
+
+ hostField.appendChild(hostWrap);
+ hostField.appendChild(removeBtn);
+
+ row.appendChild(labelField);
+ row.appendChild(hostField);
+
+ // Save on blur
+ [labelInput, hostInput].forEach(function (input) {
+ input.addEventListener('blur', function () {
+ var patch = {};
+ patch[input.dataset.field] = input.value.trim();
+ if (!patch[input.dataset.field]) return;
+ fetch(docsightUrl('/api/connection-monitor/targets/' + target.id), {
+ method: 'PUT',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify(patch)
+ }).then(function (res) {
+ if (res.ok) {
+ input.style.borderColor = 'var(--success, #10b981)';
+ setTimeout(function () { input.style.borderColor = ''; }, 1500);
+ }
+ });
+ });
+ });
+
+ // Remove target
+ removeBtn.addEventListener('click', function () {
+ var tid = removeBtn.dataset.remove;
+ fetch(docsightUrl('/api/connection-monitor/targets/' + tid), {method: 'DELETE'})
+ .then(function (res) {
+ if (res.ok) row.remove();
+ });
+ });
+
+ if (window.lucide) window.lucide.createIcons({nameAttr: 'data-lucide', nodes: [row]});
+ return row;
+ }
+
+ function loadTargets() {
+ fetch(docsightUrl('/api/connection-monitor/targets'))
+ .then(function (res) { return res.json(); })
+ .then(function (targets) {
+ while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
+ targets.forEach(function (target) {
+ listEl.appendChild(renderTarget(target));
+ });
+ })
+ .catch(function () {});
+ }
+
+ addBtn.addEventListener('click', function () {
+ fetch(docsightUrl('/api/connection-monitor/targets'), {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({label: 'New target', host: ''})
+ })
+ .then(function (res) { return res.json(); })
+ .then(function (data) {
+ if (data.id) {
+ var newRow = renderTarget({id: data.id, label: 'New target', host: ''});
+ listEl.appendChild(newRow);
+ if (window.lucide) window.lucide.createIcons({nameAttr: 'data-lucide'});
+ var hostInput = document.getElementById('cm-host-' + data.id);
+ if (hostInput) hostInput.focus();
+ }
+ })
+ .catch(function () {});
+ });
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', loadTargets);
+ } else {
+ loadTargets();
+ }
+})();
diff --git a/app/modules/connection_monitor/templates/connection_monitor_settings.html b/app/modules/connection_monitor/templates/connection_monitor_settings.html
index 43cdb248..00f17e2c 100644
--- a/app/modules/connection_monitor/templates/connection_monitor_settings.html
+++ b/app/modules/connection_monitor/templates/connection_monitor_settings.html
@@ -75,149 +75,9 @@
-
+
+
diff --git a/app/static/js/browser-contracts.js b/app/static/js/browser-contracts.js
new file mode 100644
index 00000000..57a869b3
--- /dev/null
+++ b/app/static/js/browser-contracts.js
@@ -0,0 +1,237 @@
+(function (root, factory) {
+ 'use strict';
+ var api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api;
+ if (root) Object.defineProperty(root, 'DOCSightBrowserContracts', {
+ configurable: false,
+ writable: false,
+ value: api
+ });
+})(typeof window !== 'undefined' ? window : null, function () {
+ 'use strict';
+
+ var BOOTSTRAP_ERROR = 'Invalid DOCSight bootstrap data';
+ var UNSAFE_KEYS = {__proto__: true, constructor: true, prototype: true};
+ var DRIVER_HINT_KEYS = {
+ needs_user: true,
+ needs_password: true,
+ default_url: true,
+ default_user: true,
+ username_required: true,
+ credentials_required: true,
+ url_hint: true,
+ user_hint: true,
+ password_hint: true
+ };
+
+ function failBootstrap() {
+ throw new Error(BOOTSTRAP_ERROR);
+ }
+
+ function isRecord(value) {
+ return value !== null && Object.prototype.toString.call(value) === '[object Object]';
+ }
+
+ function hasExactKeys(value, expected) {
+ var actual = Object.keys(value).sort();
+ var wanted = expected.slice().sort();
+ if (actual.length !== wanted.length) return false;
+ for (var i = 0; i < actual.length; i++) {
+ if (actual[i] !== wanted[i]) return false;
+ }
+ return true;
+ }
+
+ function isSafeJson(value, depth) {
+ depth = depth || 0;
+ if (depth > 12) return false;
+ if (value === null || typeof value === 'boolean') return true;
+ if (typeof value === 'number') return Number.isFinite(value);
+ if (typeof value === 'string') return value.length <= 20000;
+ if (Array.isArray(value)) {
+ if (value.length > 5000) return false;
+ return value.every(function (item) { return isSafeJson(item, depth + 1); });
+ }
+ if (!isRecord(value) || Object.keys(value).length > 5000) return false;
+ return Object.keys(value).every(function (key) {
+ return key.length <= 256 && !UNSAFE_KEYS[key] && isSafeJson(value[key], depth + 1);
+ });
+ }
+
+ function parseRecord(text, keys) {
+ if (typeof text !== 'string' || text.length === 0 || text.length > 1000000) failBootstrap();
+ var value;
+ try {
+ value = JSON.parse(text);
+ } catch (error) {
+ failBootstrap();
+ }
+ if (!isRecord(value) || !hasExactKeys(value, keys) || !isSafeJson(value)) failBootstrap();
+ return value;
+ }
+
+ function validLanguage(value) {
+ return value === null || (typeof value === 'string' && /^[A-Za-z]{2}(?:-[A-Za-z0-9]{2,8})?$/.test(value));
+ }
+
+ function validTranslationRecord(value) {
+ return isRecord(value) && isSafeJson(value);
+ }
+
+ function validateDriverHints(value) {
+ if (!isRecord(value)) failBootstrap();
+ Object.keys(value).forEach(function (driverId) {
+ if (!/^[A-Za-z0-9._-]{1,128}$/.test(driverId) || !isRecord(value[driverId])) failBootstrap();
+ Object.keys(value[driverId]).forEach(function (key) {
+ var hint = value[driverId][key];
+ if (!DRIVER_HINT_KEYS[key]) failBootstrap();
+ if (key === 'needs_user' || key === 'needs_password' || key === 'username_required' || key === 'credentials_required') {
+ if (typeof hint !== 'boolean') failBootstrap();
+ } else if (hint !== null && typeof hint !== 'string') {
+ failBootstrap();
+ }
+ });
+ var defaultUrl = value[driverId].default_url;
+ if (defaultUrl) {
+ var parsed;
+ try { parsed = new URL(defaultUrl); } catch (error) { failBootstrap(); }
+ if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password) failBootstrap();
+ }
+ });
+ return value;
+ }
+
+ function parseDashboardBootstrapText(text) {
+ var value = parseRecord(text, [
+ 'translations', 'language', 'temperatureUnit', 'connectionMonitorAvailable'
+ ]);
+ if (!validTranslationRecord(value.translations) || !validLanguage(value.language)) failBootstrap();
+ if (value.temperatureUnit !== 'celsius' && value.temperatureUnit !== 'fahrenheit') failBootstrap();
+ if (typeof value.connectionMonitorAvailable !== 'boolean') failBootstrap();
+ return value;
+ }
+
+ function validInternalCandidate(value) {
+ return typeof value === 'string' && value.charAt(0) === '/' && value.charAt(1) !== '/';
+ }
+
+ function parseSetupBootstrapText(text) {
+ var value = parseRecord(text, ['translations', 'driverHints', 'indexUrl', 'loginUrl']);
+ if (!validTranslationRecord(value.translations)) failBootstrap();
+ validateDriverHints(value.driverHints);
+ if (!validInternalCandidate(value.indexUrl) || !validInternalCandidate(value.loginUrl)) failBootstrap();
+ return value;
+ }
+
+ function parseConnectionMonitorBootstrapText(text) {
+ var value = parseRecord(text, ['label', 'host', 'remove']);
+ if (typeof value.label !== 'string' || typeof value.host !== 'string' || typeof value.remove !== 'string') failBootstrap();
+ return value;
+ }
+
+ function validModule(value) {
+ return isRecord(value) && hasExactKeys(value, ['id', 'labelKey', 'name']) &&
+ typeof value.id === 'string' && /^[a-z][a-z0-9_.]+$/.test(value.id) &&
+ typeof value.labelKey === 'string' &&
+ typeof value.name === 'string';
+ }
+
+ function validStringList(value) {
+ return Array.isArray(value) && value.every(function (item) {
+ return typeof item === 'string';
+ });
+ }
+
+ function parseSettingsBootstrapText(text) {
+ var value = parseRecord(text, [
+ 'translations', 'modules', 'serverOffsetMin', 'serverTimezone', 'language',
+ 'currentTimezone', 'notificationCooldowns', 'driverHints',
+ 'moduleSecretFields', 'savedModuleSecretFields'
+ ]);
+ if (!validTranslationRecord(value.translations) || !Array.isArray(value.modules) || !value.modules.every(validModule)) failBootstrap();
+ if (typeof value.serverOffsetMin !== 'number' || !Number.isFinite(value.serverOffsetMin) || Math.abs(value.serverOffsetMin) > 1440) failBootstrap();
+ if (typeof value.serverTimezone !== 'string' || value.serverTimezone.length > 128) failBootstrap();
+ if (!validLanguage(value.language) || (value.currentTimezone !== null && typeof value.currentTimezone !== 'string')) failBootstrap();
+ if (typeof value.notificationCooldowns !== 'string' || value.notificationCooldowns.length > 100000) failBootstrap();
+ validateDriverHints(value.driverHints);
+ if (!validStringList(value.moduleSecretFields) || !validStringList(value.savedModuleSecretFields)) failBootstrap();
+ if (!value.savedModuleSecretFields.every(function (key) { return value.moduleSecretFields.indexOf(key) !== -1; })) failBootstrap();
+
+ var cooldowns = {};
+ try {
+ var candidate = JSON.parse(value.notificationCooldowns);
+ if (isRecord(candidate) && isSafeJson(candidate)) cooldowns = candidate;
+ } catch (error) {
+ cooldowns = {};
+ }
+ return {
+ translations: value.translations,
+ modules: value.modules,
+ serverOffsetMin: value.serverOffsetMin,
+ serverTimezone: value.serverTimezone,
+ language: value.language,
+ currentTimezone: value.currentTimezone || '',
+ notificationCooldowns: cooldowns,
+ driverHints: value.driverHints,
+ moduleSecretFields: value.moduleSecretFields,
+ savedModuleSecretFields: value.savedModuleSecretFields
+ };
+ }
+
+ function selectSetupDriverState(driverHints, modemType, currentUrl, currentUsername, notRequiredText) {
+ var hints = isRecord(driverHints) && isRecord(driverHints[modemType]) ? driverHints[modemType] : {};
+ var knownDefaults = {};
+ Object.keys(driverHints || {}).forEach(function (key) {
+ var hint = driverHints[key];
+ if (isRecord(hint) && hint.default_url) knownDefaults[hint.default_url] = true;
+ });
+ var url = currentUrl || '';
+ if (hints.default_url && (!url || knownDefaults[url])) url = hints.default_url;
+ var credentialsVisible = hints.credentials_required !== false;
+ var usernameEnabled = credentialsVisible && hints.username_required !== false;
+ var username = usernameEnabled ? (currentUsername || '') : '';
+ var placeholder = usernameEnabled ? (hints.default_user || 'admin') : (notRequiredText || 'Not required');
+ if (usernameEnabled && !username && hints.default_user) username = hints.default_user;
+ return {
+ url: url,
+ credentialsVisible: credentialsVisible,
+ usernameEnabled: usernameEnabled,
+ username: username,
+ usernamePlaceholder: placeholder
+ };
+ }
+
+ function formatLastKnownTimestamp(value, formatter) {
+ if (!value) return '';
+ try {
+ return formatter ? formatter(value) : new Date(value).toLocaleString();
+ } catch (error) {
+ return value;
+ }
+ }
+
+ function computeServiceWorkerPolicy(hostname, search, scopeHref) {
+ var scope;
+ try { scope = new URL(scopeHref); } catch (error) { throw new Error('Invalid service-worker scope'); }
+ if ((scope.protocol !== 'http:' && scope.protocol !== 'https:') || scope.search || scope.hash || !scope.pathname.endsWith('/')) {
+ throw new Error('Invalid service-worker scope');
+ }
+ var params = new URLSearchParams(typeof search === 'string' ? search : '');
+ var local = hostname === 'localhost' || hostname === '127.0.0.1';
+ return {
+ action: local && !params.has('enable-sw-test') ? 'cleanup' : 'register',
+ scopeHref: scope.href,
+ cacheNamespace: 'docsight-' + encodeURIComponent(scope.pathname) + '-'
+ };
+ }
+
+ return {
+ parseDashboardBootstrapText: parseDashboardBootstrapText,
+ parseSetupBootstrapText: parseSetupBootstrapText,
+ parseSettingsBootstrapText: parseSettingsBootstrapText,
+ parseConnectionMonitorBootstrapText: parseConnectionMonitorBootstrapText,
+ selectSetupDriverState: selectSetupDriverState,
+ formatLastKnownTimestamp: formatLastKnownTimestamp,
+ computeServiceWorkerPolicy: computeServiceWorkerPolicy
+ };
+});
diff --git a/app/static/js/dashboard-donuts.js b/app/static/js/dashboard-donuts.js
new file mode 100644
index 00000000..e92c01de
--- /dev/null
+++ b/app/static/js/dashboard-donuts.js
@@ -0,0 +1,76 @@
+(function() {
+ 'use strict';
+
+ // Store chart instances globally to prevent memory leaks on refresh
+ window.donutCharts = window.donutCharts || {};
+
+ function renderDonut(canvasId) {
+ var c = document.getElementById(canvasId);
+ if (!c) return;
+ var container = c.parentElement;
+ if (!container) return;
+
+ var good = parseInt(container.getAttribute('data-good') || '0', 10);
+ var tolerated = parseInt(container.getAttribute('data-tolerated') || '0', 10);
+ var warn = parseInt(container.getAttribute('data-warn') || '0', 10);
+ var crit = parseInt(container.getAttribute('data-crit') || '0', 10);
+ var total = good + tolerated + warn + crit;
+ if (total === 0) return;
+
+ // Match mockup: raw Canvas arcs with thin stroke
+ var dpr = window.devicePixelRatio || 1;
+ var size = c.parentElement.offsetWidth || 80;
+ c.width = size * dpr;
+ c.height = size * dpr;
+ c.style.width = size + 'px';
+ c.style.height = size + 'px';
+ var x = c.getContext('2d');
+ x.scale(dpr, dpr);
+
+ var cx = size / 2, cy = size / 2, r = size * 0.38, lw = size * 0.08;
+ var styles = getComputedStyle(document.documentElement);
+ var segments = [
+ { val: good, color: styles.getPropertyValue('--good').trim() },
+ { val: tolerated, color: styles.getPropertyValue('--tolerated').trim() },
+ { val: warn, color: styles.getPropertyValue('--warn').trim() },
+ { val: crit, color: styles.getPropertyValue('--crit').trim() }
+ ];
+
+ // Background ring
+ x.beginPath();
+ x.arc(cx, cy, r, 0, Math.PI * 2);
+ x.strokeStyle = 'rgba(255,255,255,0.06)';
+ x.lineWidth = lw;
+ x.stroke();
+
+ // Colored segments
+ var angle = -Math.PI / 2;
+ segments.forEach(function(s) {
+ if (s.val === 0) return;
+ var sweep = (s.val / total) * Math.PI * 2;
+ x.beginPath();
+ x.arc(cx, cy, r, angle, angle + sweep);
+ x.strokeStyle = s.color;
+ x.lineWidth = lw;
+ x.lineCap = 'round';
+ x.stroke();
+ angle += sweep;
+ });
+ }
+
+ function initDonuts() {
+ renderDonut('ds-health-donut');
+ renderDonut('us-health-donut');
+ }
+
+ // Expose refresh function globally for manual updates (refreshData after innerHTML replace)
+ window.refreshDonuts = initDonuts;
+
+ // Wait for DOM to be fully loaded
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initDonuts);
+ } else {
+ // DOM already loaded (script deferred or loaded late)
+ initDonuts();
+ }
+})();
diff --git a/app/static/js/dashboard-routing.js b/app/static/js/dashboard-routing.js
new file mode 100644
index 00000000..fd212f92
--- /dev/null
+++ b/app/static/js/dashboard-routing.js
@@ -0,0 +1,10 @@
+ // Apply deferred hash-based view routing (all scripts including modules are now loaded)
+ if (window._pendingView) {
+ window.switchView(window._pendingView, true);
+ delete window._pendingView;
+ }
+ // Initialize Lucide icons after DOM is ready
+ document.addEventListener('DOMContentLoaded', function() {
+ lucide.createIcons();
+ });
+
diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js
new file mode 100644
index 00000000..25feaac1
--- /dev/null
+++ b/app/static/js/dashboard.js
@@ -0,0 +1,573 @@
+var dashboardBootstrapElement = document.getElementById('docsight-dashboard-bootstrap');
+var dashboardBootstrap = DOCSightBrowserContracts.parseDashboardBootstrapText(
+ dashboardBootstrapElement && dashboardBootstrapElement.textContent
+);
+var T = dashboardBootstrap.translations;
+var currentLang = dashboardBootstrap.language;
+var TEMPERATURE_UNIT = dashboardBootstrap.temperatureUnit;
+var CORRELATION_CM_AVAILABLE = dashboardBootstrap.connectionMonitorAvailable;
+
+(function() {
+ var LAST_KNOWN_STORAGE_KEY = 'docsight:last-known-dashboard-shell';
+
+ function rememberOnlineShell() {
+ if (!navigator.onLine) return;
+ try {
+ localStorage.setItem(LAST_KNOWN_STORAGE_KEY, new Date().toISOString());
+ } catch (err) {
+ /* Storage can be unavailable in hardened/private browser contexts. */
+ }
+ }
+
+ function updateOfflineStatus() {
+ var offlineMarker = document.querySelector('meta[name="docsight-offline-shell"][content="true"]');
+ var offline = !navigator.onLine || !!offlineMarker || window.__DOCSIGHT_OFFLINE_SHELL__ === true;
+ var banner = document.getElementById('offline-status-banner');
+ var lastKnown = document.getElementById('offline-last-known');
+ var refreshButton = document.getElementById('refresh-btn');
+ document.documentElement.classList.toggle('is-offline', offline);
+ document.body.classList.toggle('is-offline', offline);
+ if (banner) banner.hidden = !offline;
+ if (refreshButton) refreshButton.disabled = offline;
+ if (lastKnown) {
+ var stored = '';
+ try { stored = localStorage.getItem(LAST_KNOWN_STORAGE_KEY) || ''; } catch (err) {}
+ lastKnown.textContent = stored ? 'Last-known shell: ' + DOCSightBrowserContracts.formatLastKnownTimestamp(stored) : 'Last-known shell timestamp unavailable';
+ }
+ if (!offline) rememberOnlineShell();
+ }
+
+ window.updateOfflineStatus = updateOfflineStatus;
+ window.addEventListener('online', updateOfflineStatus);
+ window.addEventListener('offline', updateOfflineStatus);
+ document.addEventListener('DOMContentLoaded', updateOfflineStatus);
+ updateOfflineStatus();
+})();
+
+(function() {
+ 'use strict';
+
+ /* ── Theme ── */
+ var saved = localStorage.getItem('docsis-theme');
+ if (saved) document.documentElement.setAttribute('data-theme', saved);
+
+ var themeToggle = document.getElementById('theme-toggle-sidebar');
+ function updateThemeState() {
+ var isDark = document.documentElement.getAttribute('data-theme') !== 'light';
+ themeToggle.checked = isDark;
+ var mc = document.querySelector('meta[name="theme-color"]');
+ if (mc) mc.setAttribute('content', isDark ? '#06080f' : '#f8f6f3');
+ }
+ updateThemeState();
+ themeToggle.addEventListener('change', function() {
+ var next = this.checked ? 'dark' : 'light';
+ document.documentElement.setAttribute('data-theme', next);
+ localStorage.setItem('docsis-theme', next);
+ var mc = document.querySelector('meta[name="theme-color"]');
+ if (mc) mc.setAttribute('content', next === 'dark' ? '#06080f' : '#f8f6f3');
+ // Re-render charts with updated theme colors
+ if (typeof window.refreshDonuts === 'function') {
+ setTimeout(window.refreshDonuts, 50);
+ }
+ });
+
+ /* ── State ── */
+ /* currentView is global — defined in chart-engine.js */
+ /* charts registry is global — defined in chart-engine.js */
+ /* _trendRange → trends.js */
+ /* BQM state variables → bqm.js */
+ /* todayStr, pad, formatDateDE → chart-engine.js */
+
+ /* ── Sortable Tables ── */
+ function initSortableTables() {
+ document.querySelectorAll('table.sortable').forEach(function(table) {
+ var headers = table.querySelectorAll('th');
+ headers.forEach(function(th, colIdx) {
+ th.addEventListener('click', function() {
+ sortTable(table, colIdx, th);
+ });
+ });
+ });
+ }
+ initSortableTables();
+
+ function sortTable(table, colIdx, th) {
+ var tbody = table.querySelector('tbody');
+ var rows = Array.from(tbody.querySelectorAll('tr'));
+ var asc = th.getAttribute('data-sort-dir') !== 'asc';
+
+ table.querySelectorAll('th').forEach(function(h) {
+ h.removeAttribute('data-sort-dir');
+ h.removeAttribute('aria-sort');
+ h.classList.remove('sort-asc', 'sort-desc');
+ });
+ th.setAttribute('data-sort-dir', asc ? 'asc' : 'desc');
+ th.setAttribute('aria-sort', asc ? 'ascending' : 'descending');
+ th.classList.add(asc ? 'sort-asc' : 'sort-desc');
+
+ rows.sort(function(a, b) {
+ var cellA = a.cells[colIdx];
+ var cellB = b.cells[colIdx];
+ var valA = cellA.getAttribute('data-sort') || cellA.textContent.trim();
+ var valB = cellB.getAttribute('data-sort') || cellB.textContent.trim();
+ var numA = parseFloat(valA);
+ var numB = parseFloat(valB);
+ if (!isNaN(numA) && !isNaN(numB)) {
+ return asc ? numA - numB : numB - numA;
+ }
+ return asc ? valA.localeCompare(valB) : valB.localeCompare(valA);
+ });
+ rows.forEach(function(row) { tbody.appendChild(row); });
+ }
+
+ /* ── Sidebar ── */
+ var sidebar = document.getElementById('sidebar');
+ var sidebarBackdrop = document.getElementById('sidebar-backdrop');
+
+ function isMobile() { return window.matchMedia('(max-width: 1023px)').matches; }
+
+ var sidebarLastOpener = null;
+ var sidebarFocusableSelector = 'a[href], button, input, select, textarea, [role="button"], [tabindex]';
+
+ function getSidebarFocusables() {
+ return Array.from(sidebar.querySelectorAll(sidebarFocusableSelector));
+ }
+
+ function setSidebarFocusSuppressed(suppressed) {
+ getSidebarFocusables().forEach(function(el) {
+ if (suppressed) {
+ if (!el.hasAttribute('data-sidebar-prev-tabindex')) {
+ el.setAttribute('data-sidebar-prev-tabindex', el.getAttribute('tabindex') || '');
+ }
+ el.setAttribute('tabindex', '-1');
+ } else if (el.hasAttribute('data-sidebar-prev-tabindex')) {
+ var previous = el.getAttribute('data-sidebar-prev-tabindex');
+ if (previous) {
+ el.setAttribute('tabindex', previous);
+ } else {
+ el.removeAttribute('tabindex');
+ }
+ el.removeAttribute('data-sidebar-prev-tabindex');
+ }
+ });
+ if ('inert' in sidebar) {
+ sidebar.inert = suppressed;
+ }
+ }
+
+ function syncSidebarAccessibility() {
+ var closedOnMobile = isMobile() && !sidebar.classList.contains('open');
+ sidebar.setAttribute('aria-hidden', closedOnMobile ? 'true' : 'false');
+ setSidebarFocusSuppressed(closedOnMobile);
+ var hamburger = document.getElementById('hamburger');
+ if (hamburger) {
+ hamburger.setAttribute('aria-expanded', sidebar.classList.contains('open') ? 'true' : 'false');
+ }
+ }
+
+ function focusFirstSidebarItem() {
+ var first = getSidebarFocusables().find(function(el) {
+ return el.classList && el.classList.contains('nav-item') && !el.disabled && el.offsetParent !== null;
+ }) || getSidebarFocusables().find(function(el) {
+ return !el.disabled && el.offsetParent !== null;
+ });
+ if (first) first.focus({ preventScroll: true });
+ }
+
+ function getSidebarLinks() {
+ return Array.from(sidebar.querySelectorAll('.nav-section .nav-item[data-view]'));
+ }
+
+ function syncNavActiveState(view) {
+ getSidebarLinks().forEach(function(link) {
+ link.classList.toggle('active', link.getAttribute('data-view') === view);
+ });
+ }
+
+ window.toggleNavSection = function(labelEl) {
+ var section = labelEl.closest('.nav-section-collapsible');
+ if (section) {
+ section.classList.toggle('collapsed');
+ labelEl.setAttribute('aria-expanded', labelEl.getAttribute('aria-expanded') === 'true' ? 'false' : 'true');
+ if (typeof lucide !== 'undefined') lucide.createIcons();
+ }
+ };
+
+ window.openSidebar = function() {
+ sidebarLastOpener = document.activeElement;
+ sidebar.classList.add('open');
+ sidebarBackdrop.style.display = 'block';
+ document.body.classList.add('sidebar-open');
+ syncSidebarAccessibility();
+ window.requestAnimationFrame(focusFirstSidebarItem);
+ };
+ window.closeSidebar = function(options) {
+ options = options || {};
+ sidebar.classList.remove('open');
+ sidebarBackdrop.style.display = 'none';
+ document.body.classList.remove('sidebar-open');
+ syncSidebarAccessibility();
+ if (options.restoreFocus !== false && sidebarLastOpener && typeof sidebarLastOpener.focus === 'function') {
+ window.requestAnimationFrame(function() { sidebarLastOpener.focus({ preventScroll: true }); });
+ }
+ };
+
+ syncSidebarAccessibility();
+ window.addEventListener('resize', syncSidebarAccessibility);
+ document.addEventListener('keydown', function(event) {
+ if (event.key === 'Escape' && sidebar.classList.contains('open') && isMobile()) {
+ event.preventDefault();
+ closeSidebar();
+ }
+ });
+
+ /* Swipe-to-close sidebar (mobile) */
+ (function() {
+ var startX = 0, startY = 0, currentX = 0, swiping = false, locked = false;
+ sidebar.addEventListener('touchstart', function(e) {
+ if (!isMobile()) return;
+ startX = e.touches[0].clientX;
+ startY = e.touches[0].clientY;
+ currentX = startX;
+ swiping = true;
+ locked = false;
+ }, { passive: true });
+ sidebar.addEventListener('touchmove', function(e) {
+ if (!swiping) return;
+ currentX = e.touches[0].clientX;
+ var dx = currentX - startX;
+ var dy = e.touches[0].clientY - startY;
+ /* Lock direction on first significant movement */
+ if (!locked && (Math.abs(dx) > 10 || Math.abs(dy) > 10)) {
+ locked = true;
+ /* If vertical movement dominates, cancel swipe */
+ if (Math.abs(dy) > Math.abs(dx)) { swiping = false; return; }
+ }
+ if (locked && dx < -10) {
+ sidebar.style.transform = 'translateX(' + Math.max(dx, -280) + 'px)';
+ }
+ }, { passive: true });
+ sidebar.addEventListener('touchend', function() {
+ if (!swiping) { sidebar.style.transform = ''; return; }
+ swiping = false;
+ var dx = currentX - startX;
+ if (dx < -80) { closeSidebar(); }
+ sidebar.style.transform = '';
+ }, { passive: true });
+ })();
+
+ sidebar.addEventListener('click', function(event) {
+ var link = event.target.closest('.nav-item[data-view]');
+ if (!link || !sidebar.contains(link)) return;
+ if (isMobile()) { closeSidebar({ restoreFocus: false }); }
+ switchView(link.getAttribute('data-view'));
+ });
+ /* Nav items are now