/**
* Spaxel Dashboard - Contextual Help System
*
* Provides a searchable help overlay with fuzzy search for 30+ help articles.
* Each article has a title, explanation, and link to relevant dashboard section.
*/
(function() {
'use strict';
// ===== State =====
let helpArticles = [];
let helpOverlayVisible = false;
let searchQuery = '';
// ===== DOM Elements =====
let helpOverlay = null;
let searchInput = null;
let articlesList = null;
/**
* Initialize the help system
*/
async function init() {
// Load articles from JSON file
await loadArticles();
// Create help overlay
createHelpOverlay();
// Add help button to header
addHelpButton();
// Add keyboard shortcut (Ctrl/Cmd + ?)
document.addEventListener('keydown', handleKeyboardShortcut);
console.log('[Help] Module initialized with', helpArticles.length, 'articles');
}
/**
* Load help articles from JSON file
*/
async function loadArticles() {
try {
const response = await fetch('help_articles.json');
if (!response.ok) {
throw new Error('Failed to load help articles');
}
helpArticles = await response.json();
} catch (err) {
console.error('[Help] Failed to load articles:', err);
// Use fallback articles
helpArticles = getFallbackArticles();
}
}
/**
* Get fallback articles if JSON file fails to load
*/
function getFallbackArticles() {
return [
{
id: 'sensing-link',
title: 'What is a sensing link?',
content: 'A sensing link is the path between two spaxel nodes (one transmitting, one receiving). Motion in the space between them changes the WiFi signal, which spaxel detects.',
category: 'Basics',
action: null
},
{
id: 'detection-quality',
title: 'Why is my detection quality low?',
content: 'Low quality usually means interference from other WiFi devices, an obstacle in the sensing zone, or the node is too far from your router. Click "Diagnose" on the link to find the specific cause.',
category: 'Troubleshooting',
action: { label: 'View Fleet Status', url: '#/fleet' }
}
];
}
/**
* Create the help overlay
*/
function createHelpOverlay() {
helpOverlay = document.createElement('div');
helpOverlay.id = 'help-overlay';
helpOverlay.className = 'help-overlay';
helpOverlay.style.display = 'none';
helpOverlay.innerHTML = `
Help & Documentation
🔍
`;
document.body.appendChild(helpOverlay);
// Get references to key elements
searchInput = document.getElementById('help-search-input');
articlesList = document.getElementById('help-articles');
// Add search listener
searchInput.addEventListener('input', handleSearch);
}
/**
* Set up help button click handler.
* Note: The button already exists in HTML (id="help-btn"), this just sets up the click handler
*/
function addHelpButton() {
// The help button already exists in the HTML with id="help-btn"
// Just ensure the click handler is properly set up
const existingBtn = document.getElementById('help-btn');
if (existingBtn) {
// Remove any existing onclick handler and add our own
existingBtn.removeAttribute('onclick');
existingBtn.addEventListener('click', () => HelpOverlay.toggle());
} else {
// Fallback: try again after a delay if DOM isn't ready
setTimeout(addHelpButton, 100);
}
}
/**
* Handle keyboard shortcut (Ctrl+? or Cmd+?)
*/
function handleKeyboardShortcut(e) {
if ((e.ctrlKey || e.metaKey) && e.key === '?') {
e.preventDefault();
HelpOverlay.toggle();
} else if (e.key === 'Escape' && helpOverlayVisible) {
HelpOverlay.close();
}
}
/**
* Handle search input
*/
function handleSearch(e) {
searchQuery = e.target.value.toLowerCase().trim();
renderArticles();
}
/**
* Render articles list based on search query, sorted by relevance score.
*/
function renderArticles() {
if (!articlesList) {
return;
}
let toRender;
if (!searchQuery) {
toRender = helpArticles;
} else {
// Minimum 0.6 score so only substring/prefix matches are shown
// (subsequence-only matches score ≤0.45 and are excluded to keep results tight)
toRender = helpArticles
.map(article => {
const titleScore = scoreMatch(searchQuery, article.title) * 1.5;
const contentScore = scoreMatch(searchQuery, article.content);
const catScore = scoreMatch(searchQuery, article.category) * 0.5;
return { article, score: Math.max(titleScore, contentScore, catScore) };
})
.filter(({ score }) => score >= 0.6)
.sort((a, b) => b.score - a.score)
.map(({ article }) => article);
}
if (toRender.length === 0) {
articlesList.innerHTML = `
No articles found for "${escapeHtml(searchQuery)}"
Try different keywords or browse categories below.
`;
} else {
articlesList.innerHTML = toRender.map(article => renderArticle(article)).join('');
attachActionListeners();
}
}
/**
* Score how well a query matches text. Returns 0 if no match.
* Mirrors the command palette scoring: exact > prefix > substring > subsequence.
*/
function scoreMatch(query, text) {
if (!query) return 1;
const q = query.toLowerCase();
const t = text.toLowerCase();
if (t === q) return 1.0;
if (t.startsWith(q)) return 0.9;
if (t.includes(q)) return 0.8;
// Word-level prefix check
const words = t.split(/\s+/);
for (const w of words) {
if (w.startsWith(q)) return 0.7;
}
// Subsequence check (require ≥80% of query chars in order)
if (q.length >= 3) {
let qi = 0;
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (q[qi] === t[ti]) qi++;
}
if (qi >= q.length * 0.8) return 0.3;
}
return 0;
}
/**
* Fuzzy match helper (kept for backward compat; uses scoreMatch)
*/
function fuzzyMatch(query, text) {
return scoreMatch(query, text) > 0;
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
/**
* Render a single article
*/
function renderArticle(article) {
let actionHTML = '';
if (article.action) {
actionHTML = `
`;
}
return `