fix(build): frontend build determinism across line endings (DC-119) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

The deploy host showed phantom dist drift on every git pull: committed
dist bundles could not be reproduced on DNS2. Root cause (verified with
esbuild 0.25.12 probes): the production transform uses sourcemap:'both',
whose inline map base64-embeds RAW source bytes as sourcesContent — a CRLF
working copy (Windows dev, core.autocrlf=true) vs an LF checkout produces
different dist bytes and a different sw.js cache tag.

- build.js: normalizeSource (\r\n -> \n) on every source read (bundle
  inputs + sw.js read); exported + require.main guard so tests can import
  it without triggering a build
- .gitattributes: * text=auto eol=lf (git-layer kill of the CRLF vector)
  + binary exclusions; 9 CRLF-in-index asset files renormalized
- tests/build-determinism.test.js: 3-case pin (byte-identity after
  normalization, divergence pre-normalization, CR-strip contract) importing
  the ACTUAL normalizeSource from build.js
- package.json: declare jsdom devDependency — 2 committed test files
  require('jsdom') but it was never declared, so fresh-checkout
  npm test failed (it only passed where a stray ancestor node_modules
  happened to contain it)
- dist/features.js + sw.js: canonical deterministic rebuild
  (cache tag 3354f5fd96 -> d39ab69dd4)

Verified: status 54/54, API 2858/2858 (128 suites); CRLF-sim tree and LF
tree of same HEAD produce byte-identical dist artifacts (sha256-equal).
Judge: glm-5.3 cold round 1 = A, round 2 (post-fold) = A clean, 0 blocking.
This commit is contained in:
Hermes
2026-08-23 16:34:49 -07:00
parent 1744d1c86e
commit 4d97a11978
16 changed files with 3065 additions and 2319 deletions
+19
View File
@@ -0,0 +1,19 @@
# DC-119: normalize text file line endings at the git layer.
# The frontend build is byte-sensitive to CRLF (esbuild inline sourcemap
# embeds raw source bytes — see status/build.js DC-119 comment), and the
# Windows dev tree runs core.autocrlf=true while DNS2 checks out LF.
# eol=lf forces LF working copies for text files on ALL platforms, killing
# the phantom dist drift at the source. Binary types stay untouched.
* text=auto eol=lf
*.png binary
*.jpg binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.webp binary
*.gif binary
*.mp4 binary
*.zip binary
*.gz binary
+20 -20
View File
@@ -1,21 +1,21 @@
# Font file headers to prevent sanitizer issues # Font file headers to prevent sanitizer issues
<FilesMatch "\.(woff2|woff|ttf|eot)$"> <FilesMatch "\.(woff2|woff|ttf|eot)$">
Header set Access-Control-Allow-Origin "*" Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS" Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"
Header set Access-Control-Allow-Headers "Content-Type" Header set Access-Control-Allow-Headers "Content-Type"
Header set Cache-Control "public, max-age=31536000" Header set Cache-Control "public, max-age=31536000"
# Proper MIME types # Proper MIME types
<IfModule mod_mime.c> <IfModule mod_mime.c>
AddType font/woff2 .woff2 AddType font/woff2 .woff2
AddType font/woff .woff AddType font/woff .woff
AddType font/ttf .ttf AddType font/ttf .ttf
AddType application/vnd.ms-fontobject .eot AddType application/vnd.ms-fontobject .eot
</IfModule> </IfModule>
</FilesMatch> </FilesMatch>
# Prevent direct access to font conversion scripts # Prevent direct access to font conversion scripts
<FilesMatch "\.(py|bat)$"> <FilesMatch "\.(py|bat)$">
Order allow,deny Order allow,deny
Deny from all Deny from all
</FilesMatch> </FilesMatch>
+321 -321
View File
@@ -1,321 +1,321 @@
/** /**
* DNS Template Selector * DNS Template Selector
* Presents DNS server template options when user chooses to set up DNS * Presents DNS server template options when user chooses to set up DNS
*/ */
(function(window) { (function(window) {
'use strict'; 'use strict';
class DnsTemplateSelector { class DnsTemplateSelector {
constructor(progressTracker) { constructor(progressTracker) {
this.progressTracker = progressTracker; this.progressTracker = progressTracker;
this.modal = null; this.modal = null;
this.onTemplateSelected = null; this.onTemplateSelected = null;
console.log('[DnsTemplateSelector] Module loaded'); console.log('[DnsTemplateSelector] Module loaded');
} }
/** /**
* Get available DNS server templates from app templates * Get available DNS server templates from app templates
* @returns {Array} Array of DNS template objects * @returns {Array} Array of DNS template objects
*/ */
getDnsTemplates() { getDnsTemplates() {
// In a real implementation, this would fetch from app-templates.js // In a real implementation, this would fetch from app-templates.js
// For now, return hardcoded templates matching what we added // For now, return hardcoded templates matching what we added
return [ return [
{ {
id: 'technitium', id: 'technitium',
name: 'Technitium DNS Server', name: 'Technitium DNS Server',
description: 'Modern DNS server with web UI for managing private zones', description: 'Modern DNS server with web UI for managing private zones',
icon: '🌐', icon: '🌐',
difficulty: 'Easy', difficulty: 'Easy',
features: [ features: [
'Web-based management interface', 'Web-based management interface',
'Private zone management for .sami domain', 'Private zone management for .sami domain',
'DHCP server integration', 'DHCP server integration',
'DNS-over-HTTPS and DNS-over-TLS support' 'DNS-over-HTTPS and DNS-over-TLS support'
], ],
recommended: true recommended: true
}, },
{ {
id: 'bind9', id: 'bind9',
name: 'BIND9 DNS Server', name: 'BIND9 DNS Server',
description: 'Industry-standard DNS server - powerful and flexible', description: 'Industry-standard DNS server - powerful and flexible',
icon: '🔧', icon: '🔧',
difficulty: 'Advanced', difficulty: 'Advanced',
features: [ features: [
'Industry standard DNS server', 'Industry standard DNS server',
'Full RFC compliance', 'Full RFC compliance',
'Advanced zone management', 'Advanced zone management',
'DNSSEC support' 'DNSSEC support'
], ],
recommended: false recommended: false
}, },
{ {
id: 'pihole', id: 'pihole',
name: 'Pi-hole', name: 'Pi-hole',
description: 'Network-wide ad blocker with DNS capabilities', description: 'Network-wide ad blocker with DNS capabilities',
icon: '🛡️', icon: '🛡️',
difficulty: 'Intermediate', difficulty: 'Intermediate',
features: [ features: [
'Ad blocking at DNS level', 'Ad blocking at DNS level',
'Web interface for management', 'Web interface for management',
'DHCP server included', 'DHCP server included',
'Query logging and statistics' 'Query logging and statistics'
], ],
recommended: false recommended: false
}, },
{ {
id: 'powerdns', id: 'powerdns',
name: 'PowerDNS', name: 'PowerDNS',
description: 'High-performance DNS server with SQL backend', description: 'High-performance DNS server with SQL backend',
icon: '⚡', icon: '⚡',
difficulty: 'Intermediate', difficulty: 'Intermediate',
features: [ features: [
'SQL database backend', 'SQL database backend',
'RESTful API for automation', 'RESTful API for automation',
'Geographic load balancing', 'Geographic load balancing',
'DNSSEC support' 'DNSSEC support'
], ],
recommended: false recommended: false
}, },
{ {
id: 'coredns', id: 'coredns',
name: 'CoreDNS', name: 'CoreDNS',
description: 'Cloud-native DNS server - lightweight and flexible', description: 'Cloud-native DNS server - lightweight and flexible',
icon: '☁️', icon: '☁️',
difficulty: 'Intermediate', difficulty: 'Intermediate',
features: [ features: [
'Plugin-based architecture', 'Plugin-based architecture',
'Kubernetes-native', 'Kubernetes-native',
'Lightweight and fast', 'Lightweight and fast',
'Prometheus metrics' 'Prometheus metrics'
], ],
recommended: false recommended: false
} }
]; ];
} }
/** /**
* Show DNS template selection modal * Show DNS template selection modal
*/ */
showTemplateSelector() { showTemplateSelector() {
// Create modal if it doesn't exist // Create modal if it doesn't exist
if (!this.modal) { if (!this.modal) {
this.createModal(); this.createModal();
} }
// Populate with templates // Populate with templates
this.populateTemplates(); this.populateTemplates();
// Show modal // Show modal
this.modal.style.display = 'flex'; this.modal.style.display = 'flex';
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
} }
/** /**
* Create the modal HTML structure * Create the modal HTML structure
* @private * @private
*/ */
createModal() { createModal() {
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.id = 'dns-template-modal'; modal.id = 'dns-template-modal';
modal.className = 'dns-template-modal'; modal.className = 'dns-template-modal';
modal.innerHTML = ` modal.innerHTML = `
<div class="dns-template-modal-content"> <div class="dns-template-modal-content">
<div class="dns-template-header"> <div class="dns-template-header">
<h2>🌐 Choose a DNS Server</h2> <h2>🌐 Choose a DNS Server</h2>
<p>Setting up a DNS server is essential for managing your private .sami domain</p> <p>Setting up a DNS server is essential for managing your private .sami domain</p>
<button class="dns-template-close" aria-label="Close">&times;</button> <button class="dns-template-close" aria-label="Close">&times;</button>
</div> </div>
<div class="dns-template-grid" id="dns-template-grid"> <div class="dns-template-grid" id="dns-template-grid">
<!-- Templates will be inserted here --> <!-- Templates will be inserted here -->
</div> </div>
<div class="dns-template-footer"> <div class="dns-template-footer">
<button class="dns-template-later-btn" id="dns-setup-later">Set up later</button> <button class="dns-template-later-btn" id="dns-setup-later">Set up later</button>
</div> </div>
</div> </div>
`; `;
document.body.appendChild(modal); document.body.appendChild(modal);
this.modal = modal; this.modal = modal;
// Add event listeners // Add event listeners
modal.querySelector('.dns-template-close').addEventListener('click', () => this.close()); modal.querySelector('.dns-template-close').addEventListener('click', () => this.close());
modal.querySelector('#dns-setup-later').addEventListener('click', () => this.handleSetupLater()); modal.querySelector('#dns-setup-later').addEventListener('click', () => this.handleSetupLater());
// Close on overlay click // Close on overlay click
modal.addEventListener('click', (e) => { modal.addEventListener('click', (e) => {
if (e.target === modal) { if (e.target === modal) {
this.close(); this.close();
} }
}); });
// Close on Escape key // Close on Escape key
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.style.display === 'flex') { if (e.key === 'Escape' && modal.style.display === 'flex') {
this.close(); this.close();
} }
}); });
} }
/** /**
* Populate modal with DNS templates * Populate modal with DNS templates
* @private * @private
*/ */
populateTemplates() { populateTemplates() {
const grid = document.getElementById('dns-template-grid'); const grid = document.getElementById('dns-template-grid');
if (!grid) return; if (!grid) return;
const templates = this.getDnsTemplates(); const templates = this.getDnsTemplates();
grid.innerHTML = ''; grid.innerHTML = '';
templates.forEach(template => { templates.forEach(template => {
const card = this.createTemplateCard(template); const card = this.createTemplateCard(template);
grid.appendChild(card); grid.appendChild(card);
}); });
} }
/** /**
* Create a template card element * Create a template card element
* @private * @private
*/ */
createTemplateCard(template) { createTemplateCard(template) {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'dns-template-card'; card.className = 'dns-template-card';
if (template.recommended) { if (template.recommended) {
card.classList.add('recommended'); card.classList.add('recommended');
} }
const difficultyClass = template.difficulty.toLowerCase(); const difficultyClass = template.difficulty.toLowerCase();
card.innerHTML = ` card.innerHTML = `
${template.recommended ? '<div class="recommended-badge">Recommended</div>' : ''} ${template.recommended ? '<div class="recommended-badge">Recommended</div>' : ''}
<div class="dns-template-icon">${template.icon}</div> <div class="dns-template-icon">${template.icon}</div>
<h3>${template.name}</h3> <h3>${template.name}</h3>
<p class="dns-template-description">${template.description}</p> <p class="dns-template-description">${template.description}</p>
<div class="dns-template-difficulty difficulty-${difficultyClass}"> <div class="dns-template-difficulty difficulty-${difficultyClass}">
${template.difficulty} ${template.difficulty}
</div> </div>
<ul class="dns-template-features"> <ul class="dns-template-features">
${template.features.slice(0, 3).map(f => `<li>${f}</li>`).join('')} ${template.features.slice(0, 3).map(f => `<li>${f}</li>`).join('')}
</ul> </ul>
<button class="dns-template-select-btn" data-template-id="${template.id}"> <button class="dns-template-select-btn" data-template-id="${template.id}">
Select ${template.name} Select ${template.name}
</button> </button>
`; `;
// Add click handler to select button // Add click handler to select button
const selectBtn = card.querySelector('.dns-template-select-btn'); const selectBtn = card.querySelector('.dns-template-select-btn');
selectBtn.addEventListener('click', () => this.handleTemplateSelection(template)); selectBtn.addEventListener('click', () => this.handleTemplateSelection(template));
return card; return card;
} }
/** /**
* Handle template selection * Handle template selection
* @private * @private
*/ */
handleTemplateSelection(template) { handleTemplateSelection(template) {
console.log(`[DnsTemplateSelector] Template selected: ${template.id}`); console.log(`[DnsTemplateSelector] Template selected: ${template.id}`);
// Close modal // Close modal
this.close(); this.close();
// Trigger callback if set // Trigger callback if set
if (this.onTemplateSelected) { if (this.onTemplateSelected) {
this.onTemplateSelected(template); this.onTemplateSelected(template);
} else { } else {
// Default behavior: open app selector with DNS filter // Default behavior: open app selector with DNS filter
this.openAppSelector(template.id); this.openAppSelector(template.id);
} }
} }
/** /**
* Handle "Set up later" button * Handle "Set up later" button
* @private * @private
*/ */
handleSetupLater() { handleSetupLater() {
console.log('[DnsTemplateSelector] DNS setup deferred'); console.log('[DnsTemplateSelector] DNS setup deferred');
// Mark as deferred in progress tracker // Mark as deferred in progress tracker
if (this.progressTracker) { if (this.progressTracker) {
this.progressTracker.markDnsSetupDeferred(); this.progressTracker.markDnsSetupDeferred();
} }
// Close modal // Close modal
this.close(); this.close();
// Show notification // Show notification
this.showNotification('DNS setup deferred. You can set it up later from the App Selector.'); this.showNotification('DNS setup deferred. You can set it up later from the App Selector.');
} }
/** /**
* Open app selector with specific template * Open app selector with specific template
* @private * @private
*/ */
openAppSelector(templateId) { openAppSelector(templateId) {
// Try to open the app selector modal if it exists // Try to open the app selector modal if it exists
const appSelectorBtn = document.querySelector('[onclick*="showAppSelector"]'); const appSelectorBtn = document.querySelector('[onclick*="showAppSelector"]');
if (appSelectorBtn) { if (appSelectorBtn) {
appSelectorBtn.click(); appSelectorBtn.click();
// Wait a bit then filter to the selected template // Wait a bit then filter to the selected template
setTimeout(() => { setTimeout(() => {
const searchInput = document.querySelector('#app-search'); const searchInput = document.querySelector('#app-search');
if (searchInput) { if (searchInput) {
searchInput.value = templateId; searchInput.value = templateId;
searchInput.dispatchEvent(new Event('input', { bubbles: true })); searchInput.dispatchEvent(new Event('input', { bubbles: true }));
} }
}, 300); }, 300);
} else { } else {
// Fallback: show instructions // Fallback: show instructions
this.showNotification(`To deploy ${templateId}, use the App Selector and search for "${templateId}"`); this.showNotification(`To deploy ${templateId}, use the App Selector and search for "${templateId}"`);
} }
} }
/** /**
* Show notification message * Show notification message
* @private * @private
*/ */
showNotification(message) { showNotification(message) {
// Simple notification - could be enhanced // Simple notification - could be enhanced
const notification = document.createElement('div'); const notification = document.createElement('div');
notification.className = 'dns-template-notification'; notification.className = 'dns-template-notification';
notification.textContent = message; notification.textContent = message;
notification.style.cssText = ` notification.style.cssText = `
position: fixed; position: fixed;
top: 20px; top: 20px;
right: 20px; right: 20px;
background: var(--card-base); background: var(--card-base);
color: var(--fg); color: var(--fg);
padding: 15px 20px; padding: 15px 20px;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3); box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 10001; z-index: 10001;
max-width: 300px; max-width: 300px;
`; `;
document.body.appendChild(notification); document.body.appendChild(notification);
setTimeout(() => { setTimeout(() => {
notification.style.opacity = '0'; notification.style.opacity = '0';
notification.style.transition = 'opacity 0.3s'; notification.style.transition = 'opacity 0.3s';
setTimeout(() => notification.remove(), 300); setTimeout(() => notification.remove(), 300);
}, 3000); }, 3000);
} }
/** /**
* Close the modal * Close the modal
*/ */
close() { close() {
if (this.modal) { if (this.modal) {
this.modal.style.display = 'none'; this.modal.style.display = 'none';
document.body.style.overflow = ''; document.body.style.overflow = '';
} }
} }
} }
window.DnsTemplateSelector = DnsTemplateSelector; window.DnsTemplateSelector = DnsTemplateSelector;
console.log('[DnsTemplateSelector] Module loaded'); console.log('[DnsTemplateSelector] Module loaded');
})(window); })(window);
+259 -259
View File
@@ -1,259 +1,259 @@
/** /**
* Error Handler * Error Handler
* Handles errors gracefully without breaking the onboarding tour * Handles errors gracefully without breaking the onboarding tour
*/ */
(function(window) { (function(window) {
'use strict'; 'use strict';
class ErrorHandler { class ErrorHandler {
constructor() { constructor() {
this.errors = []; this.errors = [];
this.maxErrors = 50; // Keep last 50 errors this.maxErrors = 50; // Keep last 50 errors
} }
/** /**
* Log an error without breaking the tour * Log an error without breaking the tour
* @param {string} context - Context where error occurred * @param {string} context - Context where error occurred
* @param {Error|string} error - The error object or message * @param {Error|string} error - The error object or message
* @param {Object} metadata - Additional metadata * @param {Object} metadata - Additional metadata
*/ */
logError(context, error, metadata = {}) { logError(context, error, metadata = {}) {
const errorEntry = { const errorEntry = {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
context, context,
message: error instanceof Error ? error.message : error, message: error instanceof Error ? error.message : error,
stack: error instanceof Error ? error.stack : null, stack: error instanceof Error ? error.stack : null,
metadata metadata
}; };
// Add to errors array // Add to errors array
this.errors.push(errorEntry); this.errors.push(errorEntry);
// Keep only last maxErrors // Keep only last maxErrors
if (this.errors.length > this.maxErrors) { if (this.errors.length > this.maxErrors) {
this.errors.shift(); this.errors.shift();
} }
// Log to console // Log to console
console.error(`[Onboarding Error] ${context}:`, error, metadata); console.error(`[Onboarding Error] ${context}:`, error, metadata);
// Optionally send to error tracking service // Optionally send to error tracking service
// this.sendToErrorTracking(errorEntry); // this.sendToErrorTracking(errorEntry);
} }
/** /**
* Attempt to recover from an error and continue tour * Attempt to recover from an error and continue tour
* @param {Error} error - The error object * @param {Error} error - The error object
* @param {number} currentStep - Current step index * @param {number} currentStep - Current step index
* @returns {Object} Recovery action * @returns {Object} Recovery action
*/ */
recoverFromError(error, currentStep) { recoverFromError(error, currentStep) {
const errorType = this.classifyError(error); const errorType = this.classifyError(error);
switch (errorType) { switch (errorType) {
case 'ELEMENT_NOT_FOUND': case 'ELEMENT_NOT_FOUND':
this.logError('Element Not Found', error, { currentStep }); this.logError('Element Not Found', error, { currentStep });
return { return {
action: 'SKIP_STEP', action: 'SKIP_STEP',
nextStep: currentStep + 1, nextStep: currentStep + 1,
message: 'Target element not found, skipping to next step' message: 'Target element not found, skipping to next step'
}; };
case 'STORAGE_UNAVAILABLE': case 'STORAGE_UNAVAILABLE':
this.logError('Storage Unavailable', error); this.logError('Storage Unavailable', error);
return { return {
action: 'USE_MEMORY_STORAGE', action: 'USE_MEMORY_STORAGE',
message: 'Local storage unavailable, using in-memory storage' message: 'Local storage unavailable, using in-memory storage'
}; };
case 'DRIVER_NOT_LOADED': case 'DRIVER_NOT_LOADED':
this.logError('Driver.js Not Loaded', error); this.logError('Driver.js Not Loaded', error);
return { return {
action: 'ABORT_TOUR', action: 'ABORT_TOUR',
message: 'Driver.js library not loaded, cannot start tour' message: 'Driver.js library not loaded, cannot start tour'
}; };
case 'INVALID_TOOLTIP': case 'INVALID_TOOLTIP':
this.logError('Invalid Tooltip Configuration', error, { currentStep }); this.logError('Invalid Tooltip Configuration', error, { currentStep });
return { return {
action: 'SKIP_STEP', action: 'SKIP_STEP',
nextStep: currentStep + 1, nextStep: currentStep + 1,
message: 'Invalid tooltip configuration, skipping' message: 'Invalid tooltip configuration, skipping'
}; };
case 'THEME_DETECTION_FAILED': case 'THEME_DETECTION_FAILED':
this.logError('Theme Detection Failed', error); this.logError('Theme Detection Failed', error);
return { return {
action: 'USE_DEFAULT_THEME', action: 'USE_DEFAULT_THEME',
message: 'Using default dark theme' message: 'Using default dark theme'
}; };
default: default:
this.logError('Unknown Error', error, { currentStep }); this.logError('Unknown Error', error, { currentStep });
return { return {
action: 'ABORT_TOUR', action: 'ABORT_TOUR',
message: 'Unexpected error occurred, aborting tour' message: 'Unexpected error occurred, aborting tour'
}; };
} }
} }
/** /**
* Classify error type * Classify error type
* @private * @private
* @param {Error} error - The error object * @param {Error} error - The error object
* @returns {string} Error type * @returns {string} Error type
*/ */
classifyError(error) { classifyError(error) {
const message = error.message || error.toString(); const message = error.message || error.toString();
if (message.includes('element') && message.includes('not found')) { if (message.includes('element') && message.includes('not found')) {
return 'ELEMENT_NOT_FOUND'; return 'ELEMENT_NOT_FOUND';
} }
if (message.includes('storage') || message.includes('quota')) { if (message.includes('storage') || message.includes('quota')) {
return 'STORAGE_UNAVAILABLE'; return 'STORAGE_UNAVAILABLE';
} }
if (message.includes('driver') || message.includes('undefined')) { if (message.includes('driver') || message.includes('undefined')) {
return 'DRIVER_NOT_LOADED'; return 'DRIVER_NOT_LOADED';
} }
if (message.includes('invalid') || message.includes('validation')) { if (message.includes('invalid') || message.includes('validation')) {
return 'INVALID_TOOLTIP'; return 'INVALID_TOOLTIP';
} }
if (message.includes('theme')) { if (message.includes('theme')) {
return 'THEME_DETECTION_FAILED'; return 'THEME_DETECTION_FAILED';
} }
return 'UNKNOWN'; return 'UNKNOWN';
} }
/** /**
* Get all logged errors * Get all logged errors
* @returns {Array} Array of error entries * @returns {Array} Array of error entries
*/ */
getErrors() { getErrors() {
return [...this.errors]; return [...this.errors];
} }
/** /**
* Clear all logged errors * Clear all logged errors
*/ */
clearErrors() { clearErrors() {
this.errors = []; this.errors = [];
} }
/** /**
* Get error statistics * Get error statistics
* @returns {Object} Error statistics * @returns {Object} Error statistics
*/ */
getStatistics() { getStatistics() {
const stats = { const stats = {
total: this.errors.length, total: this.errors.length,
byContext: {}, byContext: {},
byType: {}, byType: {},
recent: this.errors.slice(-10) recent: this.errors.slice(-10)
}; };
this.errors.forEach(error => { this.errors.forEach(error => {
// Count by context // Count by context
stats.byContext[error.context] = (stats.byContext[error.context] || 0) + 1; stats.byContext[error.context] = (stats.byContext[error.context] || 0) + 1;
// Count by type // Count by type
const type = this.classifyError({ message: error.message }); const type = this.classifyError({ message: error.message });
stats.byType[type] = (stats.byType[type] || 0) + 1; stats.byType[type] = (stats.byType[type] || 0) + 1;
}); });
return stats; return stats;
} }
/** /**
* Handle graceful degradation when Driver.js fails to load * Handle graceful degradation when Driver.js fails to load
* @returns {boolean} Whether fallback was successful * @returns {boolean} Whether fallback was successful
*/ */
handleDriverLoadFailure() { handleDriverLoadFailure() {
this.logError('Driver.js Load Failure', 'Driver.js library failed to load'); this.logError('Driver.js Load Failure', 'Driver.js library failed to load');
// Show fallback message // Show fallback message
const fallbackMessage = document.createElement('div'); const fallbackMessage = document.createElement('div');
fallbackMessage.id = 'onboarding-fallback'; fallbackMessage.id = 'onboarding-fallback';
fallbackMessage.style.cssText = ` fallbackMessage.style.cssText = `
position: fixed; position: fixed;
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
background: var(--card-base, #2a2a2a); background: var(--card-base, #2a2a2a);
color: var(--fg, #ffffff); color: var(--fg, #ffffff);
padding: 15px 20px; padding: 15px 20px;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3); box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 9999; z-index: 9999;
max-width: 300px; max-width: 300px;
font-size: 14px; font-size: 14px;
`; `;
fallbackMessage.innerHTML = ` fallbackMessage.innerHTML = `
<strong>Welcome to DashCaddy!</strong><br> <strong>Welcome to DashCaddy!</strong><br>
<p style="margin: 10px 0 0 0; font-size: 12px;"> <p style="margin: 10px 0 0 0; font-size: 12px;">
The interactive tour is unavailable, but you can explore the dashboard freely. The interactive tour is unavailable, but you can explore the dashboard freely.
Check the documentation for help getting started. Check the documentation for help getting started.
</p> </p>
`; `;
document.body.appendChild(fallbackMessage); document.body.appendChild(fallbackMessage);
// Auto-remove after 10 seconds // Auto-remove after 10 seconds
setTimeout(() => { setTimeout(() => {
if (fallbackMessage.parentNode) { if (fallbackMessage.parentNode) {
fallbackMessage.parentNode.removeChild(fallbackMessage); fallbackMessage.parentNode.removeChild(fallbackMessage);
} }
}, 10000); }, 10000);
return true; return true;
} }
/** /**
* Handle storage unavailable scenario * Handle storage unavailable scenario
* @returns {Object} In-memory storage fallback * @returns {Object} In-memory storage fallback
*/ */
handleStorageUnavailable() { handleStorageUnavailable() {
this.logError('Storage Unavailable', 'Local storage is not available'); this.logError('Storage Unavailable', 'Local storage is not available');
// Create in-memory storage // Create in-memory storage
const memoryStorage = { const memoryStorage = {
data: {}, data: {},
getItem(key) { getItem(key) {
return this.data[key] || null; return this.data[key] || null;
}, },
setItem(key, value) { setItem(key, value) {
this.data[key] = value; this.data[key] = value;
}, },
removeItem(key) { removeItem(key) {
delete this.data[key]; delete this.data[key];
}, },
clear() { clear() {
this.data = {}; this.data = {};
} }
}; };
console.warn('[ErrorHandler] Using in-memory storage - progress will not persist'); console.warn('[ErrorHandler] Using in-memory storage - progress will not persist');
return memoryStorage; return memoryStorage;
} }
/** /**
* Send error to tracking service (placeholder) * Send error to tracking service (placeholder)
* @private * @private
* @param {Object} errorEntry - Error entry to send * @param {Object} errorEntry - Error entry to send
*/ */
sendToErrorTracking(errorEntry) { sendToErrorTracking(errorEntry) {
// Placeholder for error tracking integration // Placeholder for error tracking integration
// Could integrate with Sentry, LogRocket, etc. // Could integrate with Sentry, LogRocket, etc.
// Example: // Example:
// if (window.Sentry) { // if (window.Sentry) {
// Sentry.captureException(new Error(errorEntry.message), { // Sentry.captureException(new Error(errorEntry.message), {
// extra: errorEntry.metadata // extra: errorEntry.metadata
// }); // });
// } // }
} }
} }
window.ErrorHandler = ErrorHandler; window.ErrorHandler = ErrorHandler;
console.log('[ErrorHandler] Module loaded'); console.log('[ErrorHandler] Module loaded');
})(window); })(window);
+91 -91
View File
@@ -1,91 +1,91 @@
/* Sami Sans Font Family - External CSS */ /* Sami Sans Font Family - External CSS */
@font-face { @font-face {
font-family: 'Sami Sans'; font-family: 'Sami Sans';
src: url('fonts/SamiSans-Regular.woff2') format('woff2'), src: url('fonts/SamiSans-Regular.woff2') format('woff2'),
url('fonts/SamiSans-Regular.ttf') format('truetype'); url('fonts/SamiSans-Regular.ttf') format('truetype');
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'Sami Sans'; font-family: 'Sami Sans';
src: url('fonts/SamiSans-Regular.woff2') format('woff2'), src: url('fonts/SamiSans-Regular.woff2') format('woff2'),
url('fonts/SamiSans-Italic.ttf') format('truetype'); url('fonts/SamiSans-Italic.ttf') format('truetype');
font-weight: 400; font-weight: 400;
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'Sami Sans'; font-family: 'Sami Sans';
src: url('fonts/SamiSans-Medium.woff2') format('woff2'), src: url('fonts/SamiSans-Medium.woff2') format('woff2'),
url('fonts/SamiSans-Medium.ttf') format('truetype'); url('fonts/SamiSans-Medium.ttf') format('truetype');
font-weight: 500; font-weight: 500;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'Sami Sans'; font-family: 'Sami Sans';
src: url('fonts/SamiSans-SemiBold.woff2') format('woff2'), src: url('fonts/SamiSans-SemiBold.woff2') format('woff2'),
url('fonts/SamiSans-SemiBold.ttf') format('truetype'); url('fonts/SamiSans-SemiBold.ttf') format('truetype');
font-weight: 600; font-weight: 600;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'Sami Sans'; font-family: 'Sami Sans';
src: url('fonts/SamiSans-Bold.woff2') format('woff2'), src: url('fonts/SamiSans-Bold.woff2') format('woff2'),
url('fonts/SamiSans-Bold.ttf') format('truetype'); url('fonts/SamiSans-Bold.ttf') format('truetype');
font-weight: 700; font-weight: 700;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'Sami Sans'; font-family: 'Sami Sans';
src: url('fonts/SamiSans-ExtraBold.woff2') format('woff2'), src: url('fonts/SamiSans-ExtraBold.woff2') format('woff2'),
url('fonts/SamiSans-ExtraBold.ttf') format('truetype'); url('fonts/SamiSans-ExtraBold.ttf') format('truetype');
font-weight: 800; font-weight: 800;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'Sami Sans'; font-family: 'Sami Sans';
src: url('fonts/SamiSans-Black.woff2') format('woff2'), src: url('fonts/SamiSans-Black.woff2') format('woff2'),
url('fonts/SamiSans-Black.ttf') format('truetype'); url('fonts/SamiSans-Black.ttf') format('truetype');
font-weight: 900; font-weight: 900;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'Sami Sans'; font-family: 'Sami Sans';
src: url('fonts/SamiSans-Light.woff2') format('woff2'), src: url('fonts/SamiSans-Light.woff2') format('woff2'),
url('fonts/SamiSans-Light.ttf') format('truetype'); url('fonts/SamiSans-Light.ttf') format('truetype');
font-weight: 300; font-weight: 300;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'Sami Sans'; font-family: 'Sami Sans';
src: url('fonts/SamiSans-ExtraLight.woff2') format('woff2'), src: url('fonts/SamiSans-ExtraLight.woff2') format('woff2'),
url('fonts/SamiSans-ExtraLight.ttf') format('truetype'); url('fonts/SamiSans-ExtraLight.ttf') format('truetype');
font-weight: 200; font-weight: 200;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'Sami Sans'; font-family: 'Sami Sans';
src: url('fonts/SamiSans-Thin.woff2') format('woff2'), src: url('fonts/SamiSans-Thin.woff2') format('woff2'),
url('fonts/SamiSans-Thin.ttf') format('truetype'); url('fonts/SamiSans-Thin.ttf') format('truetype');
font-weight: 100; font-weight: 100;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
+354 -354
View File
@@ -1,354 +1,354 @@
/** /**
* Onboarding Tooltip Styles * Onboarding Tooltip Styles
* Custom styling for Driver.js tooltips to match DashCaddy theme * Custom styling for Driver.js tooltips to match DashCaddy theme
*/ */
/* Driver.js overrides are injected dynamically by ThemeAdapter */ /* Driver.js overrides are injected dynamically by ThemeAdapter */
/* This file contains additional custom styles */ /* This file contains additional custom styles */
.driver-popover { .driver-popover {
max-width: 500px !important; max-width: 500px !important;
z-index: 10000 !important; z-index: 10000 !important;
} }
.driver-popover-title { .driver-popover-title {
font-size: 1.2rem !important; font-size: 1.2rem !important;
margin-bottom: 12px !important; margin-bottom: 12px !important;
} }
.driver-popover-description { .driver-popover-description {
font-size: 0.95rem !important; font-size: 0.95rem !important;
line-height: 1.6 !important; line-height: 1.6 !important;
} }
.driver-popover-description p { .driver-popover-description p {
margin: 8px 0 !important; margin: 8px 0 !important;
} }
.driver-popover-description ul { .driver-popover-description ul {
margin: 8px 0 !important; margin: 8px 0 !important;
padding-left: 20px !important; padding-left: 20px !important;
} }
.driver-popover-description li { .driver-popover-description li {
margin: 4px 0 !important; margin: 4px 0 !important;
} }
.driver-popover-description code { .driver-popover-description code {
background: rgba(0, 0, 0, 0.1) !important; background: rgba(0, 0, 0, 0.1) !important;
padding: 2px 6px !important; padding: 2px 6px !important;
border-radius: 3px !important; border-radius: 3px !important;
font-family: 'Courier New', monospace !important; font-family: 'Courier New', monospace !important;
font-size: 0.9em !important; font-size: 0.9em !important;
} }
.driver-popover-footer { .driver-popover-footer {
margin-top: 16px !important; margin-top: 16px !important;
display: flex !important; display: flex !important;
gap: 8px !important; gap: 8px !important;
justify-content: flex-end !important; justify-content: flex-end !important;
} }
.driver-popover-footer button { .driver-popover-footer button {
padding: 8px 16px !important; padding: 8px 16px !important;
border-radius: 8px !important; border-radius: 8px !important;
font-size: 0.9rem !important; font-size: 0.9rem !important;
cursor: pointer !important; cursor: pointer !important;
transition: all 0.2s ease !important; transition: all 0.2s ease !important;
} }
.driver-popover-footer button:hover { .driver-popover-footer button:hover {
transform: translateY(-1px) !important; transform: translateY(-1px) !important;
} }
.driver-popover-close-btn { .driver-popover-close-btn {
position: absolute !important; position: absolute !important;
top: 12px !important; top: 12px !important;
right: 12px !important; right: 12px !important;
width: 24px !important; width: 24px !important;
height: 24px !important; height: 24px !important;
border-radius: 50% !important; border-radius: 50% !important;
display: flex !important; display: flex !important;
align-items: center !important; align-items: center !important;
justify-content: center !important; justify-content: center !important;
cursor: pointer !important; cursor: pointer !important;
opacity: 0.6 !important; opacity: 0.6 !important;
transition: opacity 0.2s ease !important; transition: opacity 0.2s ease !important;
} }
.driver-popover-close-btn:hover { .driver-popover-close-btn:hover {
opacity: 1 !important; opacity: 1 !important;
} }
.driver-popover-arrow { .driver-popover-arrow {
border-width: 8px !important; border-width: 8px !important;
} }
/* Progress indicator */ /* Progress indicator */
.driver-popover-progress-text { .driver-popover-progress-text {
font-size: 0.85rem !important; font-size: 0.85rem !important;
margin-bottom: 8px !important; margin-bottom: 8px !important;
} }
/* Mobile responsive */ /* Mobile responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.driver-popover { .driver-popover {
max-width: calc(100vw - 32px) !important; max-width: calc(100vw - 32px) !important;
} }
.driver-popover-title { .driver-popover-title {
font-size: 1.1rem !important; font-size: 1.1rem !important;
} }
.driver-popover-description { .driver-popover-description {
font-size: 0.9rem !important; font-size: 0.9rem !important;
} }
.driver-popover-footer button { .driver-popover-footer button {
padding: 6px 12px !important; padding: 6px 12px !important;
font-size: 0.85rem !important; font-size: 0.85rem !important;
} }
} }
/* Restart tour button in dashboard */ /* Restart tour button in dashboard */
#restart-tour-btn { #restart-tour-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
} }
#restart-tour-btn::before { #restart-tour-btn::before {
content: "🎓"; content: "🎓";
font-size: 1.1em; font-size: 1.1em;
} }
/* DNS Template Selector Modal */ /* DNS Template Selector Modal */
.dns-template-modal { .dns-template-modal {
display: none; display: none;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.8); background: rgba(0, 0, 0, 0.8);
z-index: 10000; z-index: 10000;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 20px; padding: 20px;
} }
.dns-template-modal-content { .dns-template-modal-content {
background: var(--card-base); background: var(--card-base);
border-radius: 12px; border-radius: 12px;
max-width: 900px; max-width: 900px;
width: 100%; width: 100%;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
} }
.dns-template-header { .dns-template-header {
padding: 30px; padding: 30px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
position: relative; position: relative;
} }
.dns-template-header h2 { .dns-template-header h2 {
margin: 0 0 10px 0; margin: 0 0 10px 0;
color: var(--fg); color: var(--fg);
font-size: 28px; font-size: 28px;
} }
.dns-template-header p { .dns-template-header p {
margin: 0; margin: 0;
color: var(--fg-muted); color: var(--fg-muted);
font-size: 14px; font-size: 14px;
} }
.dns-template-close { .dns-template-close {
position: absolute; position: absolute;
top: 20px; top: 20px;
right: 20px; right: 20px;
background: none; background: none;
border: none; border: none;
font-size: 32px; font-size: 32px;
color: var(--fg-muted); color: var(--fg-muted);
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
width: 40px; width: 40px;
height: 40px; height: 40px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 50%; border-radius: 50%;
transition: all 0.2s; transition: all 0.2s;
} }
.dns-template-close:hover { .dns-template-close:hover {
background: var(--hover); background: var(--hover);
color: var(--fg); color: var(--fg);
} }
.dns-template-grid { .dns-template-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px; gap: 20px;
padding: 30px; padding: 30px;
} }
.dns-template-card { .dns-template-card {
background: var(--card-hover); background: var(--card-hover);
border: 2px solid var(--border); border: 2px solid var(--border);
border-radius: 12px; border-radius: 12px;
padding: 20px; padding: 20px;
transition: all 0.3s; transition: all 0.3s;
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.dns-template-card:hover { .dns-template-card:hover {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
border-color: var(--accent); border-color: var(--accent);
} }
.dns-template-card.recommended { .dns-template-card.recommended {
border-color: var(--accent); border-color: var(--accent);
background: linear-gradient(135deg, var(--card-hover) 0%, var(--card-base) 100%); background: linear-gradient(135deg, var(--card-hover) 0%, var(--card-base) 100%);
} }
.recommended-badge { .recommended-badge {
position: absolute; position: absolute;
top: -10px; top: -10px;
right: 20px; right: 20px;
background: var(--accent); background: var(--accent);
color: white; color: white;
padding: 4px 12px; padding: 4px 12px;
border-radius: 12px; border-radius: 12px;
font-size: 11px; font-size: 11px;
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.dns-template-icon { .dns-template-icon {
font-size: 48px; font-size: 48px;
margin-bottom: 15px; margin-bottom: 15px;
text-align: center; text-align: center;
} }
.dns-template-card h3 { .dns-template-card h3 {
margin: 0 0 10px 0; margin: 0 0 10px 0;
color: var(--fg); color: var(--fg);
font-size: 18px; font-size: 18px;
text-align: center; text-align: center;
} }
.dns-template-description { .dns-template-description {
color: var(--fg-muted); color: var(--fg-muted);
font-size: 13px; font-size: 13px;
margin: 0 0 15px 0; margin: 0 0 15px 0;
text-align: center; text-align: center;
flex-grow: 1; flex-grow: 1;
} }
.dns-template-difficulty { .dns-template-difficulty {
display: inline-block; display: inline-block;
padding: 4px 12px; padding: 4px 12px;
border-radius: 12px; border-radius: 12px;
font-size: 11px; font-size: 11px;
font-weight: bold; font-weight: bold;
text-align: center; text-align: center;
margin: 0 auto 15px auto; margin: 0 auto 15px auto;
} }
.difficulty-easy { .difficulty-easy {
background: #2ecc71; background: #2ecc71;
color: white; color: white;
} }
.difficulty-intermediate { .difficulty-intermediate {
background: #f39c12; background: #f39c12;
color: white; color: white;
} }
.difficulty-advanced { .difficulty-advanced {
background: #e74c3c; background: #e74c3c;
color: white; color: white;
} }
.dns-template-features { .dns-template-features {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0 0 20px 0; margin: 0 0 20px 0;
font-size: 12px; font-size: 12px;
color: var(--fg-muted); color: var(--fg-muted);
} }
.dns-template-features li { .dns-template-features li {
padding: 6px 0; padding: 6px 0;
padding-left: 20px; padding-left: 20px;
position: relative; position: relative;
} }
.dns-template-features li:before { .dns-template-features li:before {
content: "✓"; content: "✓";
position: absolute; position: absolute;
left: 0; left: 0;
color: var(--accent); color: var(--accent);
font-weight: bold; font-weight: bold;
} }
.dns-template-select-btn { .dns-template-select-btn {
background: var(--accent); background: var(--accent);
color: white; color: white;
border: none; border: none;
padding: 12px 20px; padding: 12px 20px;
border-radius: 8px; border-radius: 8px;
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
width: 100%; width: 100%;
} }
.dns-template-select-btn:hover { .dns-template-select-btn:hover {
background: var(--accent-strong); background: var(--accent-strong);
transform: scale(1.02); transform: scale(1.02);
} }
.dns-template-footer { .dns-template-footer {
padding: 20px 30px; padding: 20px 30px;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
text-align: center; text-align: center;
} }
.dns-template-later-btn { .dns-template-later-btn {
background: transparent; background: transparent;
color: var(--fg-muted); color: var(--fg-muted);
border: 1px solid var(--border); border: 1px solid var(--border);
padding: 10px 24px; padding: 10px 24px;
border-radius: 8px; border-radius: 8px;
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.dns-template-later-btn:hover { .dns-template-later-btn:hover {
background: var(--hover); background: var(--hover);
color: var(--fg); color: var(--fg);
border-color: var(--fg-muted); border-color: var(--fg-muted);
} }
/* Responsive design */ /* Responsive design */
@media (max-width: 768px) { @media (max-width: 768px) {
.dns-template-grid { .dns-template-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.dns-template-modal-content { .dns-template-modal-content {
max-height: 95vh; max-height: 95vh;
} }
} }
+177 -177
View File
@@ -1,177 +1,177 @@
/** /**
* DashCaddy User Onboarding System * DashCaddy User Onboarding System
* Main entry point for the tooltip-based onboarding experience * Main entry point for the tooltip-based onboarding experience
* *
* This file initializes the onboarding system and coordinates between * This file initializes the onboarding system and coordinates between
* the various components (TourManager, ProgressTracker, ThemeAdapter, etc.) * the various components (TourManager, ProgressTracker, ThemeAdapter, etc.)
*/ */
(function() { (function() {
'use strict'; 'use strict';
let progressTracker; let progressTracker;
let themeAdapter; let themeAdapter;
let tourManager; let tourManager;
let dnsTemplateSelector; let dnsTemplateSelector;
let errorHandler; let errorHandler;
/** /**
* Initialize the onboarding system * Initialize the onboarding system
*/ */
async function initializeOnboarding() { async function initializeOnboarding() {
try { try {
console.log('[Onboarding] Initializing system...'); console.log('[Onboarding] Initializing system...');
// Initialize Error Handler first // Initialize Error Handler first
errorHandler = new ErrorHandler(); errorHandler = new ErrorHandler();
console.log('[Onboarding] Error Handler initialized'); console.log('[Onboarding] Error Handler initialized');
// Initialize Progress Tracker // Initialize Progress Tracker
progressTracker = new ProgressTracker('dashcaddy_onboarding'); progressTracker = new ProgressTracker('dashcaddy_onboarding');
console.log('[Onboarding] Progress Tracker initialized'); console.log('[Onboarding] Progress Tracker initialized');
// Initialize Theme Adapter // Initialize Theme Adapter
themeAdapter = new ThemeAdapter(); themeAdapter = new ThemeAdapter();
console.log('[Onboarding] Theme Adapter initialized'); console.log('[Onboarding] Theme Adapter initialized');
// Initialize DNS Template Selector // Initialize DNS Template Selector
dnsTemplateSelector = new DnsTemplateSelector(progressTracker); dnsTemplateSelector = new DnsTemplateSelector(progressTracker);
console.log('[Onboarding] DNS Template Selector initialized'); console.log('[Onboarding] DNS Template Selector initialized');
// Initialize Tour Manager // Initialize Tour Manager
tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector); tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector);
console.log('[Onboarding] Tour Manager initialized'); console.log('[Onboarding] Tour Manager initialized');
// Check if tour should auto-start // Check if tour should auto-start
if (tourManager.shouldAutoStart()) { if (tourManager.shouldAutoStart()) {
console.log('[Onboarding] Auto-starting tour for first-time user'); console.log('[Onboarding] Auto-starting tour for first-time user');
// Wait a bit for page to fully load // Wait a bit for page to fully load
setTimeout(() => { setTimeout(() => {
tourManager.startTour(); tourManager.startTour();
}, 1000); }, 1000);
} else { } else {
const tourCompleted = progressTracker.isTourCompleted(); const tourCompleted = progressTracker.isTourCompleted();
const currentStep = progressTracker.getCurrentStep(); const currentStep = progressTracker.getCurrentStep();
console.log(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`); console.log(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`);
// If tour is in progress, offer to resume // If tour is in progress, offer to resume
if (!tourCompleted && currentStep > 0) { if (!tourCompleted && currentStep > 0) {
console.log('[Onboarding] Tour in progress, can be resumed manually'); console.log('[Onboarding] Tour in progress, can be resumed manually');
} }
} }
// Add restart tour button to tools row // Add restart tour button to tools row
addRestartTourButton(); addRestartTourButton();
// Expose to global scope for manual triggering // Expose to global scope for manual triggering
window.DashCaddyOnboarding = { window.DashCaddyOnboarding = {
startTour: () => tourManager.startTour(), startTour: () => tourManager.startTour(),
restartTour: () => tourManager.restartTour(), restartTour: () => tourManager.restartTour(),
showTooltip: (id) => tourManager.showTooltip(id), showTooltip: (id) => tourManager.showTooltip(id),
showWhatsNew: () => tourManager.showWhatsNew(), showWhatsNew: () => tourManager.showWhatsNew(),
resetProgress: () => progressTracker.resetProgress(), resetProgress: () => progressTracker.resetProgress(),
getErrors: () => errorHandler.getErrors(), getErrors: () => errorHandler.getErrors(),
getErrorStats: () => errorHandler.getStatistics() getErrorStats: () => errorHandler.getStatistics()
}; };
console.log('[Onboarding] System initialized successfully'); console.log('[Onboarding] System initialized successfully');
} catch (error) { } catch (error) {
console.error('[Onboarding] Initialization error:', error); console.error('[Onboarding] Initialization error:', error);
// Use error handler if available // Use error handler if available
if (errorHandler) { if (errorHandler) {
errorHandler.logError('Initialization', error); errorHandler.logError('Initialization', error);
} }
// Graceful degradation - don't break the dashboard // Graceful degradation - don't break the dashboard
console.warn('[Onboarding] System failed to initialize, dashboard will continue without onboarding'); console.warn('[Onboarding] System failed to initialize, dashboard will continue without onboarding');
} }
} }
/** /**
* Add restart tour button to tools row * Add restart tour button to tools row
*/ */
function addRestartTourButton() { function addRestartTourButton() {
const toolsRow = document.querySelector('.tools'); const toolsRow = document.querySelector('.tools');
if (!toolsRow) return; if (!toolsRow) return;
const clickHandler = () => { const clickHandler = () => {
if (tourManager) { if (tourManager) {
console.log('[Onboarding] Starting tour via button click'); console.log('[Onboarding] Starting tour via button click');
tourManager.restartTour(); tourManager.restartTour();
} else { } else {
console.error('[Onboarding] Tour manager not initialized'); console.error('[Onboarding] Tour manager not initialized');
alert('Tour is not available. Check browser console for errors.\n\nPossible issues:\n- Driver.js library failed to load\n- JavaScript errors during initialization'); alert('Tour is not available. Check browser console for errors.\n\nPossible issues:\n- Driver.js library failed to load\n- JavaScript errors during initialization');
} }
}; };
// If button already exists in the HTML, just attach the handler // If button already exists in the HTML, just attach the handler
const existing = document.getElementById('restart-tour-btn'); const existing = document.getElementById('restart-tour-btn');
if (existing) { if (existing) {
existing.onclick = clickHandler; existing.onclick = clickHandler;
return; return;
} }
const button = document.createElement('button'); const button = document.createElement('button');
button.id = 'restart-tour-btn'; button.id = 'restart-tour-btn';
button.textContent = 'Help Tour'; button.textContent = 'Help Tour';
button.title = 'Restart the onboarding tour'; button.title = 'Restart the onboarding tour';
button.onclick = clickHandler; button.onclick = clickHandler;
toolsRow.appendChild(button); toolsRow.appendChild(button);
} }
/** /**
* Check if Driver.js is loaded * Check if Driver.js is loaded
*/ */
function checkDriverLoaded() { function checkDriverLoaded() {
// Driver.js v1.x IIFE: window.driver.js.driver is the factory function // Driver.js v1.x IIFE: window.driver.js.driver is the factory function
const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver; const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver;
if (typeof driverFactory !== 'function') { if (typeof driverFactory !== 'function') {
console.warn('[Onboarding] Driver.js not loaded yet, will retry... window.driver:', window.driver); console.warn('[Onboarding] Driver.js not loaded yet, will retry... window.driver:', window.driver);
return false; return false;
} }
return true; return true;
} }
/** /**
* Wait for Driver.js to load, then initialize * Wait for Driver.js to load, then initialize
*/ */
function waitForDriver() { function waitForDriver() {
let retries = 0; let retries = 0;
const maxRetries = 10; const maxRetries = 10;
function attemptInit() { function attemptInit() {
if (checkDriverLoaded()) { if (checkDriverLoaded()) {
initializeOnboarding(); initializeOnboarding();
} else { } else {
retries++; retries++;
if (retries < maxRetries) { if (retries < maxRetries) {
// Retry after a short delay // Retry after a short delay
setTimeout(attemptInit, 500); setTimeout(attemptInit, 500);
} else { } else {
// Max retries reached, show fallback // Max retries reached, show fallback
console.error('[Onboarding] Driver.js failed to load after multiple attempts'); console.error('[Onboarding] Driver.js failed to load after multiple attempts');
if (errorHandler) { if (errorHandler) {
errorHandler.handleDriverLoadFailure(); errorHandler.handleDriverLoadFailure();
} else { } else {
// Create temporary error handler for fallback // Create temporary error handler for fallback
const tempHandler = new ErrorHandler(); const tempHandler = new ErrorHandler();
tempHandler.handleDriverLoadFailure(); tempHandler.handleDriverLoadFailure();
} }
} }
} }
} }
attemptInit(); attemptInit();
} }
// Start initialization when DOM is ready // Start initialization when DOM is ready
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', waitForDriver); document.addEventListener('DOMContentLoaded', waitForDriver);
} else { } else {
waitForDriver(); waitForDriver();
} }
console.log('[Onboarding] System loaded'); console.log('[Onboarding] System loaded');
})(); })();
+282 -282
View File
@@ -1,282 +1,282 @@
/** /**
* Progress Tracker * Progress Tracker
* Manages persistent storage of user progress through the onboarding flow * Manages persistent storage of user progress through the onboarding flow
* using browser local storage. * using browser local storage.
* *
* Storage Schema: * Storage Schema:
* { * {
* "version": "1.0", * "version": "1.0",
* "tourCompleted": false, * "tourCompleted": false,
* "completedTooltips": ["welcome", "dns-priority", ...], * "completedTooltips": ["welcome", "dns-priority", ...],
* "currentStep": 3, * "currentStep": 3,
* "completionTimestamp": "2024-01-15T10:30:00Z", * "completionTimestamp": "2024-01-15T10:30:00Z",
* "dnsSetupDeferred": false, * "dnsSetupDeferred": false,
* "lastVisit": "2024-01-15T10:30:00Z" * "lastVisit": "2024-01-15T10:30:00Z"
* } * }
*/ */
(function(window) { (function(window) {
'use strict'; 'use strict';
/** /**
* ProgressTracker class * ProgressTracker class
* Manages persistent storage of onboarding progress * Manages persistent storage of onboarding progress
* *
* @class * @class
* @param {string} storageKey - The key to use for local storage (default: 'dashcaddy_onboarding') * @param {string} storageKey - The key to use for local storage (default: 'dashcaddy_onboarding')
*/ */
class ProgressTracker { class ProgressTracker {
constructor(storageKey = 'dashcaddy_onboarding') { constructor(storageKey = 'dashcaddy_onboarding') {
this.storageKey = storageKey; this.storageKey = storageKey;
this.storageVersion = '1.0'; this.storageVersion = '1.0';
// Initialize storage if it doesn't exist // Initialize storage if it doesn't exist
this._initializeStorage(); this._initializeStorage();
// Update last visit timestamp // Update last visit timestamp
this._updateLastVisit(); this._updateLastVisit();
} }
/** /**
* Initialize storage with default values if it doesn't exist * Initialize storage with default values if it doesn't exist
* @private * @private
*/ */
_initializeStorage() { _initializeStorage() {
const existing = this._getStorage(); const existing = this._getStorage();
if (!existing || existing.version !== this.storageVersion) { if (!existing || existing.version !== this.storageVersion) {
const defaultState = { const defaultState = {
version: this.storageVersion, version: this.storageVersion,
tourCompleted: false, tourCompleted: false,
completedTooltips: [], completedTooltips: [],
currentStep: 0, currentStep: 0,
completionTimestamp: null, completionTimestamp: null,
dnsSetupDeferred: false, dnsSetupDeferred: false,
lastVisit: new Date().toISOString() lastVisit: new Date().toISOString()
}; };
this._setStorage(defaultState); this._setStorage(defaultState);
} }
} }
/** /**
* Get the current storage state * Get the current storage state
* @private * @private
* @returns {Object|null} The storage state or null if unavailable * @returns {Object|null} The storage state or null if unavailable
*/ */
_getStorage() { _getStorage() {
try { try {
const data = localStorage.getItem(this.storageKey); const data = localStorage.getItem(this.storageKey);
return data ? JSON.parse(data) : null; return data ? JSON.parse(data) : null;
} catch (error) { } catch (error) {
console.error('[ProgressTracker] Error reading from storage:', error); console.error('[ProgressTracker] Error reading from storage:', error);
return null; return null;
} }
} }
/** /**
* Set the storage state * Set the storage state
* @private * @private
* @param {Object} state - The state to save * @param {Object} state - The state to save
*/ */
_setStorage(state) { _setStorage(state) {
try { try {
localStorage.setItem(this.storageKey, JSON.stringify(state)); localStorage.setItem(this.storageKey, JSON.stringify(state));
} catch (error) { } catch (error) {
console.error('[ProgressTracker] Error writing to storage:', error); console.error('[ProgressTracker] Error writing to storage:', error);
// Handle quota exceeded or storage unavailable // Handle quota exceeded or storage unavailable
// Fall back to session storage or in-memory storage // Fall back to session storage or in-memory storage
this._handleStorageError(error); this._handleStorageError(error);
} }
} }
/** /**
* Handle storage errors (quota exceeded, unavailable, etc.) * Handle storage errors (quota exceeded, unavailable, etc.)
* @private * @private
* @param {Error} error - The error that occurred * @param {Error} error - The error that occurred
*/ */
_handleStorageError(error) { _handleStorageError(error) {
// Try session storage as fallback // Try session storage as fallback
try { try {
sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage())); sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage()));
console.warn('[ProgressTracker] Falling back to session storage'); console.warn('[ProgressTracker] Falling back to session storage');
} catch (sessionError) { } catch (sessionError) {
console.error('[ProgressTracker] Session storage also unavailable:', sessionError); console.error('[ProgressTracker] Session storage also unavailable:', sessionError);
// Could implement in-memory fallback here if needed // Could implement in-memory fallback here if needed
} }
} }
/** /**
* Update the last visit timestamp * Update the last visit timestamp
* @private * @private
*/ */
_updateLastVisit() { _updateLastVisit() {
const state = this._getStorage(); const state = this._getStorage();
if (state) { if (state) {
state.lastVisit = new Date().toISOString(); state.lastVisit = new Date().toISOString();
this._setStorage(state); this._setStorage(state);
} }
} }
/** /**
* Check if a specific tooltip has been completed * Check if a specific tooltip has been completed
* @param {string} tooltipId - The ID of the tooltip to check * @param {string} tooltipId - The ID of the tooltip to check
* @returns {boolean} True if the tooltip has been completed * @returns {boolean} True if the tooltip has been completed
*/ */
isTooltipCompleted(tooltipId) { isTooltipCompleted(tooltipId) {
const state = this._getStorage(); const state = this._getStorage();
if (!state) return false; if (!state) return false;
return state.completedTooltips.includes(tooltipId); return state.completedTooltips.includes(tooltipId);
} }
/** /**
* Mark a tooltip as completed with timestamp * Mark a tooltip as completed with timestamp
* @param {string} tooltipId - The ID of the tooltip to mark as completed * @param {string} tooltipId - The ID of the tooltip to mark as completed
*/ */
markTooltipCompleted(tooltipId) { markTooltipCompleted(tooltipId) {
const state = this._getStorage(); const state = this._getStorage();
if (!state) return; if (!state) return;
// Add tooltip to completed list if not already there // Add tooltip to completed list if not already there
if (!state.completedTooltips.includes(tooltipId)) { if (!state.completedTooltips.includes(tooltipId)) {
state.completedTooltips.push(tooltipId); state.completedTooltips.push(tooltipId);
// Store timestamp for this specific tooltip // Store timestamp for this specific tooltip
if (!state.tooltipTimestamps) { if (!state.tooltipTimestamps) {
state.tooltipTimestamps = {}; state.tooltipTimestamps = {};
} }
state.tooltipTimestamps[tooltipId] = new Date().toISOString(); state.tooltipTimestamps[tooltipId] = new Date().toISOString();
this._setStorage(state); this._setStorage(state);
} }
} }
/** /**
* Check if the entire tour has been completed * Check if the entire tour has been completed
* @returns {boolean} True if the tour is completed * @returns {boolean} True if the tour is completed
*/ */
isTourCompleted() { isTourCompleted() {
const state = this._getStorage(); const state = this._getStorage();
if (!state) return false; if (!state) return false;
return state.tourCompleted === true; return state.tourCompleted === true;
} }
/** /**
* Mark the entire tour as completed * Mark the entire tour as completed
*/ */
markTourCompleted() { markTourCompleted() {
const state = this._getStorage(); const state = this._getStorage();
if (!state) return; if (!state) return;
state.tourCompleted = true; state.tourCompleted = true;
state.completionTimestamp = new Date().toISOString(); state.completionTimestamp = new Date().toISOString();
this._setStorage(state); this._setStorage(state);
} }
/** /**
* Get the current step index * Get the current step index
* @returns {number} The current step index (0-based) * @returns {number} The current step index (0-based)
*/ */
getCurrentStep() { getCurrentStep() {
const state = this._getStorage(); const state = this._getStorage();
if (!state) return 0; if (!state) return 0;
return state.currentStep || 0; return state.currentStep || 0;
} }
/** /**
* Set the current step index * Set the current step index
* @param {number} stepIndex - The step index to set (0-based) * @param {number} stepIndex - The step index to set (0-based)
*/ */
setCurrentStep(stepIndex) { setCurrentStep(stepIndex) {
const state = this._getStorage(); const state = this._getStorage();
if (!state) return; if (!state) return;
state.currentStep = stepIndex; state.currentStep = stepIndex;
this._setStorage(state); this._setStorage(state);
} }
/** /**
* Reset all progress and clear storage * Reset all progress and clear storage
*/ */
resetProgress() { resetProgress() {
const defaultState = { const defaultState = {
version: this.storageVersion, version: this.storageVersion,
tourCompleted: false, tourCompleted: false,
completedTooltips: [], completedTooltips: [],
currentStep: 0, currentStep: 0,
completionTimestamp: null, completionTimestamp: null,
dnsSetupDeferred: false, dnsSetupDeferred: false,
lastVisit: new Date().toISOString() lastVisit: new Date().toISOString()
}; };
this._setStorage(defaultState); this._setStorage(defaultState);
} }
/** /**
* Get the completion timestamp * Get the completion timestamp
* @returns {Date|null} The completion timestamp or null if not completed * @returns {Date|null} The completion timestamp or null if not completed
*/ */
getCompletionTimestamp() { getCompletionTimestamp() {
const state = this._getStorage(); const state = this._getStorage();
if (!state || !state.completionTimestamp) return null; if (!state || !state.completionTimestamp) return null;
return new Date(state.completionTimestamp); return new Date(state.completionTimestamp);
} }
/** /**
* Check if DNS setup was deferred * Check if DNS setup was deferred
* @returns {boolean} True if DNS setup was deferred * @returns {boolean} True if DNS setup was deferred
*/ */
isDnsSetupDeferred() { isDnsSetupDeferred() {
const state = this._getStorage(); const state = this._getStorage();
if (!state) return false; if (!state) return false;
return state.dnsSetupDeferred === true; return state.dnsSetupDeferred === true;
} }
/** /**
* Mark DNS setup as deferred * Mark DNS setup as deferred
*/ */
markDnsSetupDeferred() { markDnsSetupDeferred() {
const state = this._getStorage(); const state = this._getStorage();
if (!state) return; if (!state) return;
state.dnsSetupDeferred = true; state.dnsSetupDeferred = true;
this._setStorage(state); this._setStorage(state);
} }
/** /**
* Get the timestamp for a specific tooltip completion * Get the timestamp for a specific tooltip completion
* @param {string} tooltipId - The ID of the tooltip * @param {string} tooltipId - The ID of the tooltip
* @returns {Date|null} The timestamp or null if not completed * @returns {Date|null} The timestamp or null if not completed
*/ */
getTooltipTimestamp(tooltipId) { getTooltipTimestamp(tooltipId) {
const state = this._getStorage(); const state = this._getStorage();
if (!state || !state.tooltipTimestamps || !state.tooltipTimestamps[tooltipId]) { if (!state || !state.tooltipTimestamps || !state.tooltipTimestamps[tooltipId]) {
return null; return null;
} }
return new Date(state.tooltipTimestamps[tooltipId]); return new Date(state.tooltipTimestamps[tooltipId]);
} }
/** /**
* Get all completed tooltip IDs * Get all completed tooltip IDs
* @returns {string[]} Array of completed tooltip IDs * @returns {string[]} Array of completed tooltip IDs
*/ */
getCompletedTooltips() { getCompletedTooltips() {
const state = this._getStorage(); const state = this._getStorage();
if (!state) return []; if (!state) return [];
return state.completedTooltips || []; return state.completedTooltips || [];
} }
/** /**
* Get the last visit timestamp * Get the last visit timestamp
* @returns {Date|null} The last visit timestamp * @returns {Date|null} The last visit timestamp
*/ */
getLastVisit() { getLastVisit() {
const state = this._getStorage(); const state = this._getStorage();
if (!state || !state.lastVisit) return null; if (!state || !state.lastVisit) return null;
return new Date(state.lastVisit); return new Date(state.lastVisit);
} }
} }
// Export to global scope // Export to global scope
window.ProgressTracker = ProgressTracker; window.ProgressTracker = ProgressTracker;
console.log('[ProgressTracker] Module loaded'); console.log('[ProgressTracker] Module loaded');
})(window); })(window);
+337 -337
View File
@@ -1,337 +1,337 @@
/** /**
* Tooltip Definitions * Tooltip Definitions
* Defines all tooltip content, positioning, and behavior for the onboarding system * Defines all tooltip content, positioning, and behavior for the onboarding system
*/ */
(function(window) { (function(window) {
'use strict'; 'use strict';
/** /**
* Validate a tooltip definition * Validate a tooltip definition
* @param {Object} tooltip - The tooltip definition to validate * @param {Object} tooltip - The tooltip definition to validate
* @returns {Object} { valid: boolean, errors: string[] } * @returns {Object} { valid: boolean, errors: string[] }
*/ */
function validateTooltipDefinition(tooltip) { function validateTooltipDefinition(tooltip) {
const errors = []; const errors = [];
// Required fields // Required fields
if (!tooltip.id || typeof tooltip.id !== 'string') { if (!tooltip.id || typeof tooltip.id !== 'string') {
errors.push('Tooltip must have a valid string id'); errors.push('Tooltip must have a valid string id');
} }
if (!tooltip.element) { if (!tooltip.element) {
errors.push('Tooltip must have an element selector or HTMLElement'); errors.push('Tooltip must have an element selector or HTMLElement');
} }
if (!tooltip.popover || typeof tooltip.popover !== 'object') { if (!tooltip.popover || typeof tooltip.popover !== 'object') {
errors.push('Tooltip must have a popover object'); errors.push('Tooltip must have a popover object');
} else { } else {
// Validate popover fields // Validate popover fields
if (!tooltip.popover.title || typeof tooltip.popover.title !== 'string') { if (!tooltip.popover.title || typeof tooltip.popover.title !== 'string') {
errors.push('Tooltip popover must have a valid string title'); errors.push('Tooltip popover must have a valid string title');
} }
if (!tooltip.popover.description || typeof tooltip.popover.description !== 'string') { if (!tooltip.popover.description || typeof tooltip.popover.description !== 'string') {
errors.push('Tooltip popover must have a valid string description'); errors.push('Tooltip popover must have a valid string description');
} }
// Validate position if provided // Validate position if provided
if (tooltip.popover.position) { if (tooltip.popover.position) {
const validPositions = ['top', 'bottom', 'left', 'right', 'center']; const validPositions = ['top', 'bottom', 'left', 'right', 'center'];
if (!validPositions.includes(tooltip.popover.position)) { if (!validPositions.includes(tooltip.popover.position)) {
errors.push(`Invalid position: ${tooltip.popover.position}. Must be one of: ${validPositions.join(', ')}`); errors.push(`Invalid position: ${tooltip.popover.position}. Must be one of: ${validPositions.join(', ')}`);
} }
} }
// Validate align if provided // Validate align if provided
if (tooltip.popover.align) { if (tooltip.popover.align) {
const validAligns = ['start', 'center', 'end']; const validAligns = ['start', 'center', 'end'];
if (!validAligns.includes(tooltip.popover.align)) { if (!validAligns.includes(tooltip.popover.align)) {
errors.push(`Invalid align: ${tooltip.popover.align}. Must be one of: ${validAligns.join(', ')}`); errors.push(`Invalid align: ${tooltip.popover.align}. Must be one of: ${validAligns.join(', ')}`);
} }
} }
// Validate showButtons if provided // Validate showButtons if provided
if (tooltip.popover.showButtons && !Array.isArray(tooltip.popover.showButtons)) { if (tooltip.popover.showButtons && !Array.isArray(tooltip.popover.showButtons)) {
errors.push('showButtons must be an array'); errors.push('showButtons must be an array');
} }
// Validate callbacks if provided // Validate callbacks if provided
const callbacks = ['onNext', 'onPrevious', 'onClose', 'onSetupNow', 'onLater']; const callbacks = ['onNext', 'onPrevious', 'onClose', 'onSetupNow', 'onLater'];
callbacks.forEach(callback => { callbacks.forEach(callback => {
if (tooltip.popover[callback] && typeof tooltip.popover[callback] !== 'function') { if (tooltip.popover[callback] && typeof tooltip.popover[callback] !== 'function') {
errors.push(`${callback} must be a function`); errors.push(`${callback} must be a function`);
} }
}); });
} }
// Validate condition if provided // Validate condition if provided
if (tooltip.condition && typeof tooltip.condition !== 'function') { if (tooltip.condition && typeof tooltip.condition !== 'function') {
errors.push('condition must be a function'); errors.push('condition must be a function');
} }
// Validate priority if provided // Validate priority if provided
if (tooltip.priority !== undefined && typeof tooltip.priority !== 'number') { if (tooltip.priority !== undefined && typeof tooltip.priority !== 'number') {
errors.push('priority must be a number'); errors.push('priority must be a number');
} }
return { return {
valid: errors.length === 0, valid: errors.length === 0,
errors errors
}; };
} }
/** /**
* Validate an array of tooltip definitions * Validate an array of tooltip definitions
* @param {Array} tooltips - Array of tooltip definitions * @param {Array} tooltips - Array of tooltip definitions
* @returns {Object} { valid: boolean, errors: Object[] } * @returns {Object} { valid: boolean, errors: Object[] }
*/ */
function validateTooltipDefinitions(tooltips) { function validateTooltipDefinitions(tooltips) {
if (!Array.isArray(tooltips)) { if (!Array.isArray(tooltips)) {
return { return {
valid: false, valid: false,
errors: [{ tooltip: null, errors: ['tooltips must be an array'] }] errors: [{ tooltip: null, errors: ['tooltips must be an array'] }]
}; };
} }
const allErrors = []; const allErrors = [];
const ids = new Set(); const ids = new Set();
tooltips.forEach((tooltip, index) => { tooltips.forEach((tooltip, index) => {
const validation = validateTooltipDefinition(tooltip); const validation = validateTooltipDefinition(tooltip);
if (!validation.valid) { if (!validation.valid) {
allErrors.push({ allErrors.push({
tooltip: tooltip.id || `index ${index}`, tooltip: tooltip.id || `index ${index}`,
errors: validation.errors errors: validation.errors
}); });
} }
// Check for duplicate IDs // Check for duplicate IDs
if (tooltip.id) { if (tooltip.id) {
if (ids.has(tooltip.id)) { if (ids.has(tooltip.id)) {
allErrors.push({ allErrors.push({
tooltip: tooltip.id, tooltip: tooltip.id,
errors: [`Duplicate tooltip ID: ${tooltip.id}`] errors: [`Duplicate tooltip ID: ${tooltip.id}`]
}); });
} }
ids.add(tooltip.id); ids.add(tooltip.id);
} }
}); });
return { return {
valid: allErrors.length === 0, valid: allErrors.length === 0,
errors: allErrors errors: allErrors
}; };
} }
/** /**
* Error handler for tooltip system * Error handler for tooltip system
*/ */
class TooltipError extends Error { class TooltipError extends Error {
constructor(message, tooltipId = null) { constructor(message, tooltipId = null) {
super(message); super(message);
this.name = 'TooltipError'; this.name = 'TooltipError';
this.tooltipId = tooltipId; this.tooltipId = tooltipId;
} }
} }
/** /**
* Handle tooltip definition errors * Handle tooltip definition errors
* @param {Object} validation - Validation result * @param {Object} validation - Validation result
* @throws {TooltipError} If validation fails * @throws {TooltipError} If validation fails
*/ */
function handleValidationErrors(validation) { function handleValidationErrors(validation) {
if (!validation.valid) { if (!validation.valid) {
const errorMessages = validation.errors.map(e => const errorMessages = validation.errors.map(e =>
`${e.tooltip}: ${e.errors.join(', ')}` `${e.tooltip}: ${e.errors.join(', ')}`
).join('\n'); ).join('\n');
console.error('[TooltipDefinitions] Validation errors:', errorMessages); console.error('[TooltipDefinitions] Validation errors:', errorMessages);
throw new TooltipError(`Tooltip validation failed:\n${errorMessages}`); throw new TooltipError(`Tooltip validation failed:\n${errorMessages}`);
} }
} }
// Export to global scope // Export to global scope
window.TooltipValidation = { window.TooltipValidation = {
validateTooltipDefinition, validateTooltipDefinition,
validateTooltipDefinitions, validateTooltipDefinitions,
handleValidationErrors, handleValidationErrors,
TooltipError TooltipError
}; };
console.log('[TooltipDefinitions] Validation module loaded'); console.log('[TooltipDefinitions] Validation module loaded');
})(window); })(window);
/** /**
* Tooltip Definitions Array * Tooltip Definitions Array
* Defines all tooltips for the onboarding tour * Defines all tooltips for the onboarding tour
*/ */
const TOOLTIP_DEFINITIONS = [ const TOOLTIP_DEFINITIONS = [
// 1. Welcome tooltip pointing to logo // 1. Welcome tooltip pointing to logo
{ {
id: 'welcome', id: 'welcome',
element: '#brand', element: '#brand',
popover: { popover: {
title: 'Welcome to DashCaddy!', title: 'Welcome to DashCaddy!',
description: ` description: `
<p>Your personal dashboard for managing services with Caddy reverse proxy.</p> <p>Your personal dashboard for managing services with Caddy reverse proxy.</p>
<p>Let's take a quick tour to help you get started.</p> <p>Let's take a quick tour to help you get started.</p>
<p style="margin-top: 8px; font-size: 0.85rem; opacity: 0.8;">Tip: You can customize this logo in Settings.</p> <p style="margin-top: 8px; font-size: 0.85rem; opacity: 0.8;">Tip: You can customize this logo in Settings.</p>
`, `,
position: 'bottom', position: 'bottom',
align: 'start', align: 'start',
showButtons: ['next'], showButtons: ['next'],
showProgress: true showProgress: true
}, },
priority: 1, priority: 1,
isNewFeature: false isNewFeature: false
}, },
// 2. Add Service button // 2. Add Service button
{ {
id: 'add-service', id: 'add-service',
element: '#add-service-btn', element: '#add-service-btn',
popover: { popover: {
title: 'Adding New Services', title: 'Adding New Services',
description: ` description: `
<p>Click <strong>+ Add Service</strong> to deploy new apps or add existing services to your dashboard.</p> <p>Click <strong>+ Add Service</strong> to deploy new apps or add existing services to your dashboard.</p>
<p>Choose from 50+ templates including:</p> <p>Choose from 50+ templates including:</p>
<ul> <ul>
<li>Media servers (Plex, Jellyfin, Emby)</li> <li>Media servers (Plex, Jellyfin, Emby)</li>
<li>Download managers (qBittorrent, Transmission)</li> <li>Download managers (qBittorrent, Transmission)</li>
<li>DNS servers (Technitium, Pi-hole)</li> <li>DNS servers (Technitium, Pi-hole)</li>
</ul> </ul>
`, `,
position: 'bottom', position: 'bottom',
showButtons: ['previous', 'next'], showButtons: ['previous', 'next'],
showProgress: true showProgress: true
}, },
priority: 2, priority: 2,
isNewFeature: false, isNewFeature: false,
condition: () => { condition: () => {
return document.getElementById('add-service-btn') !== null; return document.getElementById('add-service-btn') !== null;
} }
}, },
// 3. App Grid explanation // 3. App Grid explanation
{ {
id: 'app-grid', id: 'app-grid',
element: '#cards', element: '#cards',
popover: { popover: {
title: 'Your Services', title: 'Your Services',
description: ` description: `
<p>This is your service grid where all your deployed applications appear.</p> <p>This is your service grid where all your deployed applications appear.</p>
<p>Each card shows:</p> <p>Each card shows:</p>
<ul> <ul>
<li>Service status (online/offline)</li> <li>Service status (online/offline)</li>
<li>Response time</li> <li>Response time</li>
<li>Quick actions (restart, open, logs, settings)</li> <li>Quick actions (restart, open, logs, settings)</li>
</ul> </ul>
`, `,
position: 'top', position: 'top',
showButtons: ['previous', 'next'], showButtons: ['previous', 'next'],
showProgress: true showProgress: true
}, },
priority: 3, priority: 3,
isNewFeature: false isNewFeature: false
}, },
// 4. Theme selector // 4. Theme selector
{ {
id: 'theme-selector', id: 'theme-selector',
element: '#theme', element: '#theme',
popover: { popover: {
title: 'Customize Your Theme', title: 'Customize Your Theme',
description: ` description: `
<p>DashCaddy comes with 7 themes. Click here to switch between them.</p> <p>DashCaddy comes with 7 themes. Click here to switch between them.</p>
<p>Your preference is saved automatically.</p> <p>Your preference is saved automatically.</p>
`, `,
position: 'bottom', position: 'bottom',
showButtons: ['previous', 'close'], showButtons: ['previous', 'close'],
showProgress: true showProgress: true
}, },
priority: 4, priority: 4,
isNewFeature: false, isNewFeature: false,
condition: () => { condition: () => {
return document.getElementById('theme') !== null; return document.getElementById('theme') !== null;
} }
} }
]; ];
/** /**
* Get tooltip definitions * Get tooltip definitions
* @returns {Array} Array of tooltip definitions * @returns {Array} Array of tooltip definitions
*/ */
function getTooltipDefinitions() { function getTooltipDefinitions() {
return TOOLTIP_DEFINITIONS; return TOOLTIP_DEFINITIONS;
} }
/** /**
* Get a specific tooltip by ID * Get a specific tooltip by ID
* @param {string} id - Tooltip ID * @param {string} id - Tooltip ID
* @returns {Object|null} Tooltip definition or null if not found * @returns {Object|null} Tooltip definition or null if not found
*/ */
function getTooltipById(id) { function getTooltipById(id) {
return TOOLTIP_DEFINITIONS.find(t => t.id === id) || null; return TOOLTIP_DEFINITIONS.find(t => t.id === id) || null;
} }
/** /**
* Get tooltips filtered by condition * Get tooltips filtered by condition
* @returns {Array} Array of tooltips that pass their condition check * @returns {Array} Array of tooltips that pass their condition check
*/ */
function getActiveTooltips() { function getActiveTooltips() {
return TOOLTIP_DEFINITIONS.filter(tooltip => { return TOOLTIP_DEFINITIONS.filter(tooltip => {
if (tooltip.condition && typeof tooltip.condition === 'function') { if (tooltip.condition && typeof tooltip.condition === 'function') {
try { try {
return tooltip.condition(); return tooltip.condition();
} catch (error) { } catch (error) {
console.error(`[TooltipDefinitions] Error evaluating condition for ${tooltip.id}:`, error); console.error(`[TooltipDefinitions] Error evaluating condition for ${tooltip.id}:`, error);
return false; return false;
} }
} }
return true; return true;
}); });
} }
/** /**
* Get tooltips sorted by priority * Get tooltips sorted by priority
* @returns {Array} Array of tooltips sorted by priority (ascending) * @returns {Array} Array of tooltips sorted by priority (ascending)
*/ */
function getSortedTooltips() { function getSortedTooltips() {
const tooltips = getActiveTooltips(); const tooltips = getActiveTooltips();
return tooltips.sort((a, b) => { return tooltips.sort((a, b) => {
const priorityA = a.priority || 999; const priorityA = a.priority || 999;
const priorityB = b.priority || 999; const priorityB = b.priority || 999;
return priorityA - priorityB; return priorityA - priorityB;
}); });
} }
/** /**
* Get tooltips marked as new features * Get tooltips marked as new features
* @returns {Array} Array of tooltips marked with isNewFeature flag * @returns {Array} Array of tooltips marked with isNewFeature flag
*/ */
function getNewFeatureTooltips() { function getNewFeatureTooltips() {
const tooltips = getActiveTooltips(); const tooltips = getActiveTooltips();
return tooltips.filter(tooltip => tooltip.isNewFeature === true) return tooltips.filter(tooltip => tooltip.isNewFeature === true)
.sort((a, b) => { .sort((a, b) => {
const priorityA = a.priority || 999; const priorityA = a.priority || 999;
const priorityB = b.priority || 999; const priorityB = b.priority || 999;
return priorityA - priorityB; return priorityA - priorityB;
}); });
} }
// Export to global scope // Export to global scope
window.TooltipDefinitions = { window.TooltipDefinitions = {
TOOLTIP_DEFINITIONS, TOOLTIP_DEFINITIONS,
getTooltipDefinitions, getTooltipDefinitions,
getTooltipById, getTooltipById,
getActiveTooltips, getActiveTooltips,
getSortedTooltips, getSortedTooltips,
getNewFeatureTooltips getNewFeatureTooltips
}; };
console.log('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips'); console.log('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips');
+363 -363
View File
@@ -1,363 +1,363 @@
/** /**
* Tour Manager * Tour Manager
* Orchestrates the onboarding tour using Driver.js * Orchestrates the onboarding tour using Driver.js
*/ */
(function(window) { (function(window) {
'use strict'; 'use strict';
class TourManager { class TourManager {
constructor(progressTracker, themeAdapter, dnsTemplateSelector) { constructor(progressTracker, themeAdapter, dnsTemplateSelector) {
this.progressTracker = progressTracker; this.progressTracker = progressTracker;
this.themeAdapter = themeAdapter; this.themeAdapter = themeAdapter;
this.dnsTemplateSelector = dnsTemplateSelector; this.dnsTemplateSelector = dnsTemplateSelector;
this.driver = null; this.driver = null;
this.currentStepIndex = 0; this.currentStepIndex = 0;
this.isActive = false; this.isActive = false;
this.resizeHandler = null; this.resizeHandler = null;
this.layoutChangeHandler = null; this.layoutChangeHandler = null;
} }
/** /**
* Initialize Driver.js with theme-aware configuration * Initialize Driver.js with theme-aware configuration
*/ */
async initializeDriver() { async initializeDriver() {
// Driver.js v1.x IIFE: window.driver.js.driver is the factory function // Driver.js v1.x IIFE: window.driver.js.driver is the factory function
const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver; const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver;
if (typeof driverFactory !== 'function') { if (typeof driverFactory !== 'function') {
console.error('[TourManager] Driver.js not loaded or invalid. window.driver:', window.driver); console.error('[TourManager] Driver.js not loaded or invalid. window.driver:', window.driver);
return false; return false;
} }
const themeConfig = this.themeAdapter.getDriverTheme(); const themeConfig = this.themeAdapter.getDriverTheme();
this.driver = driverFactory({ this.driver = driverFactory({
showProgress: true, showProgress: true,
showButtons: ['next', 'previous', 'close'], showButtons: ['next', 'previous', 'close'],
allowClose: true, allowClose: true,
overlayClickNext: false, overlayClickNext: false,
overlayOpacity: 0, overlayOpacity: 0,
stagePadding: 0, stagePadding: 0,
stageRadius: 0, stageRadius: 0,
allowKeyboardControl: true, allowKeyboardControl: true,
popoverClass: 'dashcaddy-popover', popoverClass: 'dashcaddy-popover',
onDestroyed: () => this.onTourComplete(), onDestroyed: () => this.onTourComplete(),
onDestroyStarted: () => { onDestroyStarted: () => {
if (!this.progressTracker.isTourCompleted()) { if (!this.progressTracker.isTourCompleted()) {
this.onTourSkip(); this.onTourSkip();
} }
} }
}); });
// Apply theme // Apply theme
this.themeAdapter.applyTheme(this.driver); this.themeAdapter.applyTheme(this.driver);
// Listen for theme changes // Listen for theme changes
this.themeAdapter.onThemeChange(() => { this.themeAdapter.onThemeChange(() => {
this.themeAdapter.applyTheme(this.driver); this.themeAdapter.applyTheme(this.driver);
}); });
// Set up dynamic repositioning // Set up dynamic repositioning
this.setupDynamicRepositioning(); this.setupDynamicRepositioning();
return true; return true;
} }
/** /**
* Check if tour should auto-start * Check if tour should auto-start
*/ */
shouldAutoStart() { shouldAutoStart() {
return !this.progressTracker.isTourCompleted() && return !this.progressTracker.isTourCompleted() &&
this.progressTracker.getCurrentStep() === 0; this.progressTracker.getCurrentStep() === 0;
} }
/** /**
* Start the onboarding tour * Start the onboarding tour
*/ */
async startTour() { async startTour() {
if (!this.driver) { if (!this.driver) {
const initialized = await this.initializeDriver(); const initialized = await this.initializeDriver();
if (!initialized) return; if (!initialized) return;
} }
// Get active tooltips (filtered by conditions) // Get active tooltips (filtered by conditions)
const allTooltips = window.TooltipDefinitions.getSortedTooltips(); const allTooltips = window.TooltipDefinitions.getSortedTooltips();
// Filter out completed tooltips // Filter out completed tooltips
const completedIds = this.progressTracker.getCompletedTooltips(); const completedIds = this.progressTracker.getCompletedTooltips();
const activeTooltips = allTooltips.filter(t => !completedIds.includes(t.id)); const activeTooltips = allTooltips.filter(t => !completedIds.includes(t.id));
if (activeTooltips.length === 0) { if (activeTooltips.length === 0) {
console.log('[TourManager] No tooltips to show'); console.log('[TourManager] No tooltips to show');
this.progressTracker.markTourCompleted(); this.progressTracker.markTourCompleted();
return; return;
} }
// Convert to Driver.js steps with navigation logic // Convert to Driver.js steps with navigation logic
const steps = activeTooltips.map((tooltip, index) => { const steps = activeTooltips.map((tooltip, index) => {
const isFirst = index === 0; const isFirst = index === 0;
const isLast = index === activeTooltips.length - 1; const isLast = index === activeTooltips.length - 1;
const step = { const step = {
element: tooltip.element, element: tooltip.element,
popover: { popover: {
title: tooltip.popover.title, title: tooltip.popover.title,
description: tooltip.popover.description, description: tooltip.popover.description,
side: tooltip.popover.position || 'bottom', side: tooltip.popover.position || 'bottom',
align: tooltip.popover.align || 'start', align: tooltip.popover.align || 'start',
showButtons: this._getButtonsForStep(tooltip, isFirst, isLast), showButtons: this._getButtonsForStep(tooltip, isFirst, isLast),
showProgress: tooltip.popover.showProgress !== false, showProgress: tooltip.popover.showProgress !== false,
onNextClick: () => { onNextClick: () => {
this.progressTracker.markTooltipCompleted(tooltip.id); this.progressTracker.markTooltipCompleted(tooltip.id);
this.progressTracker.setCurrentStep(index + 1); this.progressTracker.setCurrentStep(index + 1);
this.currentStepIndex = index + 1; this.currentStepIndex = index + 1;
this.driver.moveNext(); this.driver.moveNext();
}, },
onPrevClick: () => { onPrevClick: () => {
this.progressTracker.setCurrentStep(Math.max(0, index - 1)); this.progressTracker.setCurrentStep(Math.max(0, index - 1));
this.currentStepIndex = Math.max(0, index - 1); this.currentStepIndex = Math.max(0, index - 1);
this.driver.movePrevious(); this.driver.movePrevious();
}, },
onCloseClick: () => { onCloseClick: () => {
this.skipTour(); this.skipTour();
} }
} }
}; };
// Add custom handlers for DNS tooltip // Add custom handlers for DNS tooltip
if (tooltip.id === 'dns-priority' && this.dnsTemplateSelector) { if (tooltip.id === 'dns-priority' && this.dnsTemplateSelector) {
step.popover.onSetupNowClick = () => { step.popover.onSetupNowClick = () => {
console.log('[TourManager] Opening DNS template selector'); console.log('[TourManager] Opening DNS template selector');
this.dnsTemplateSelector.showTemplateSelector(); this.dnsTemplateSelector.showTemplateSelector();
// Mark tooltip as completed and move to next // Mark tooltip as completed and move to next
this.progressTracker.markTooltipCompleted(tooltip.id); this.progressTracker.markTooltipCompleted(tooltip.id);
this.progressTracker.setCurrentStep(index + 1); this.progressTracker.setCurrentStep(index + 1);
this.currentStepIndex = index + 1; this.currentStepIndex = index + 1;
this.driver.moveNext(); this.driver.moveNext();
}; };
step.popover.onLaterClick = () => { step.popover.onLaterClick = () => {
console.log('[TourManager] DNS setup deferred'); console.log('[TourManager] DNS setup deferred');
this.progressTracker.markDnsSetupDeferred(); this.progressTracker.markDnsSetupDeferred();
// Mark tooltip as completed and move to next // Mark tooltip as completed and move to next
this.progressTracker.markTooltipCompleted(tooltip.id); this.progressTracker.markTooltipCompleted(tooltip.id);
this.progressTracker.setCurrentStep(index + 1); this.progressTracker.setCurrentStep(index + 1);
this.currentStepIndex = index + 1; this.currentStepIndex = index + 1;
this.driver.moveNext(); this.driver.moveNext();
}; };
} }
return step; return step;
}); });
this.isActive = true; this.isActive = true;
this.driver.setSteps(steps); this.driver.setSteps(steps);
this.driver.drive(); this.driver.drive();
} }
/** /**
* Resume tour from last step * Resume tour from last step
*/ */
async resumeTour() { async resumeTour() {
const currentStep = this.progressTracker.getCurrentStep(); const currentStep = this.progressTracker.getCurrentStep();
if (currentStep > 0) { if (currentStep > 0) {
await this.startTour(); await this.startTour();
// Driver.js will start from beginning, we'd need to skip to current step // Driver.js will start from beginning, we'd need to skip to current step
// This is a simplified implementation // This is a simplified implementation
} else { } else {
await this.startTour(); await this.startTour();
} }
} }
/** /**
* Skip the entire tour * Skip the entire tour
*/ */
skipTour() { skipTour() {
if (this.driver) { if (this.driver) {
this.driver.destroy(); this.driver.destroy();
} }
this.cleanupDynamicRepositioning(); this.cleanupDynamicRepositioning();
this.isActive = false; this.isActive = false;
} }
/** /**
* Restart tour from beginning * Restart tour from beginning
*/ */
async restartTour() { async restartTour() {
this.progressTracker.resetProgress(); this.progressTracker.resetProgress();
await this.startTour(); await this.startTour();
} }
/** /**
* Show a specific tooltip by ID * Show a specific tooltip by ID
*/ */
async showTooltip(tooltipId) { async showTooltip(tooltipId) {
const tooltip = window.TooltipDefinitions.getTooltipById(tooltipId); const tooltip = window.TooltipDefinitions.getTooltipById(tooltipId);
if (!tooltip) { if (!tooltip) {
console.error(`[TourManager] Tooltip not found: ${tooltipId}`); console.error(`[TourManager] Tooltip not found: ${tooltipId}`);
return; return;
} }
if (!this.driver) { if (!this.driver) {
await this.initializeDriver(); await this.initializeDriver();
} }
const step = { const step = {
element: tooltip.element, element: tooltip.element,
popover: { popover: {
title: tooltip.popover.title, title: tooltip.popover.title,
description: tooltip.popover.description, description: tooltip.popover.description,
side: tooltip.popover.position || 'bottom', side: tooltip.popover.position || 'bottom',
align: tooltip.popover.align || 'start' align: tooltip.popover.align || 'start'
} }
}; };
this.driver.highlight(step); this.driver.highlight(step);
} }
/** /**
* Show "What's New" tour - only tooltips marked as new features * Show "What's New" tour - only tooltips marked as new features
*/ */
async showWhatsNew() { async showWhatsNew() {
if (!this.driver) { if (!this.driver) {
const initialized = await this.initializeDriver(); const initialized = await this.initializeDriver();
if (!initialized) return; if (!initialized) return;
} }
// Get only new feature tooltips // Get only new feature tooltips
const newFeatureTooltips = window.TooltipDefinitions.getNewFeatureTooltips(); const newFeatureTooltips = window.TooltipDefinitions.getNewFeatureTooltips();
if (newFeatureTooltips.length === 0) { if (newFeatureTooltips.length === 0) {
console.log('[TourManager] No new features to show'); console.log('[TourManager] No new features to show');
return; return;
} }
console.log(`[TourManager] Showing ${newFeatureTooltips.length} new features`); console.log(`[TourManager] Showing ${newFeatureTooltips.length} new features`);
// Convert to Driver.js steps // Convert to Driver.js steps
const steps = newFeatureTooltips.map((tooltip, index) => { const steps = newFeatureTooltips.map((tooltip, index) => {
const isFirst = index === 0; const isFirst = index === 0;
const isLast = index === newFeatureTooltips.length - 1; const isLast = index === newFeatureTooltips.length - 1;
return { return {
element: tooltip.element, element: tooltip.element,
popover: { popover: {
title: `✨ NEW: ${tooltip.popover.title}`, title: `✨ NEW: ${tooltip.popover.title}`,
description: tooltip.popover.description, description: tooltip.popover.description,
side: tooltip.popover.position || 'bottom', side: tooltip.popover.position || 'bottom',
align: tooltip.popover.align || 'start', align: tooltip.popover.align || 'start',
showButtons: this._getButtonsForStep(tooltip, isFirst, isLast), showButtons: this._getButtonsForStep(tooltip, isFirst, isLast),
showProgress: true, showProgress: true,
onNextClick: () => { onNextClick: () => {
this.driver.moveNext(); this.driver.moveNext();
}, },
onPrevClick: () => { onPrevClick: () => {
this.driver.movePrevious(); this.driver.movePrevious();
}, },
onCloseClick: () => { onCloseClick: () => {
this.skipTour(); this.skipTour();
} }
} }
}; };
}); });
this.isActive = true; this.isActive = true;
this.driver.setSteps(steps); this.driver.setSteps(steps);
this.driver.drive(); this.driver.drive();
} }
/** /**
* Set up dynamic repositioning for window resize and layout changes * Set up dynamic repositioning for window resize and layout changes
*/ */
setupDynamicRepositioning() { setupDynamicRepositioning() {
// Window resize handler with debouncing // Window resize handler with debouncing
let resizeTimeout; let resizeTimeout;
this.resizeHandler = () => { this.resizeHandler = () => {
clearTimeout(resizeTimeout); clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => { resizeTimeout = setTimeout(() => {
if (this.isActive && this.driver) { if (this.isActive && this.driver) {
console.log('[TourManager] Window resized, repositioning tooltip'); console.log('[TourManager] Window resized, repositioning tooltip');
this.driver.refresh(); this.driver.refresh();
} }
}, 150); // Debounce for 150ms }, 150); // Debounce for 150ms
}; };
// Layout change handler (for theme changes, DOM mutations) // Layout change handler (for theme changes, DOM mutations)
this.layoutChangeHandler = () => { this.layoutChangeHandler = () => {
if (this.isActive && this.driver) { if (this.isActive && this.driver) {
console.log('[TourManager] Layout changed, repositioning tooltip'); console.log('[TourManager] Layout changed, repositioning tooltip');
// Small delay to allow layout to settle // Small delay to allow layout to settle
setTimeout(() => { setTimeout(() => {
if (this.driver) { if (this.driver) {
this.driver.refresh(); this.driver.refresh();
} }
}, 100); }, 100);
} }
}; };
// Add event listeners // Add event listeners
window.addEventListener('resize', this.resizeHandler); window.addEventListener('resize', this.resizeHandler);
// Listen for theme changes (already handled by ThemeAdapter, but also trigger reposition) // Listen for theme changes (already handled by ThemeAdapter, but also trigger reposition)
this.themeAdapter.onThemeChange(this.layoutChangeHandler); this.themeAdapter.onThemeChange(this.layoutChangeHandler);
} }
/** /**
* Clean up dynamic repositioning listeners * Clean up dynamic repositioning listeners
*/ */
cleanupDynamicRepositioning() { cleanupDynamicRepositioning() {
if (this.resizeHandler) { if (this.resizeHandler) {
window.removeEventListener('resize', this.resizeHandler); window.removeEventListener('resize', this.resizeHandler);
} }
} }
/** /**
* Get buttons to show for a specific step * Get buttons to show for a specific step
* @private * @private
*/ */
_getButtonsForStep(tooltip, isFirst, isLast) { _getButtonsForStep(tooltip, isFirst, isLast) {
// Check if tooltip has custom buttons defined // Check if tooltip has custom buttons defined
if (tooltip.popover.showButtons) { if (tooltip.popover.showButtons) {
return tooltip.popover.showButtons; return tooltip.popover.showButtons;
} }
// Default button configuration // Default button configuration
const buttons = []; const buttons = [];
if (!isFirst) { if (!isFirst) {
buttons.push('previous'); buttons.push('previous');
} }
if (!isLast) { if (!isLast) {
buttons.push('next'); buttons.push('next');
} else { } else {
buttons.push('close'); buttons.push('close');
} }
return buttons; return buttons;
} }
/** /**
* Handle tour completion * Handle tour completion
*/ */
onTourComplete() { onTourComplete() {
this.progressTracker.markTourCompleted(); this.progressTracker.markTourCompleted();
this.isActive = false; this.isActive = false;
console.log('[TourManager] Tour completed'); console.log('[TourManager] Tour completed');
} }
/** /**
* Handle tour skip * Handle tour skip
*/ */
onTourSkip() { onTourSkip() {
// Save current progress but don't mark as completed // Save current progress but don't mark as completed
console.log('[TourManager] Tour skipped'); console.log('[TourManager] Tour skipped');
this.isActive = false; this.isActive = false;
} }
} }
window.TourManager = TourManager; window.TourManager = TourManager;
console.log('[TourManager] Module loaded'); console.log('[TourManager] Module loaded');
})(window); })(window);
+49 -17
View File
@@ -3,6 +3,12 @@ const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const esbuild = require('esbuild'); const esbuild = require('esbuild');
// DC-119: single source of truth for the CRLF->LF normalization applied to
// every source read before minification (see the long comment in build()).
// Exported so tests/build-determinism.test.js pins THE ACTUAL regex, not a
// re-implementation that would silently drift if this one changes.
const normalizeSource = (s) => s.replace(/\r\n/g, '\n');
const JS = (...parts) => path.join(__dirname, 'js', ...parts); const JS = (...parts) => path.join(__dirname, 'js', ...parts);
const DIST = path.join(__dirname, 'dist'); const DIST = path.join(__dirname, 'dist');
const INDEX_HTML = path.join(__dirname, 'index.html'); const INDEX_HTML = path.join(__dirname, 'index.html');
@@ -149,7 +155,20 @@ async function build() {
console.warn(` WARN: ${path.relative(__dirname, file)} not found, skipping`); console.warn(` WARN: ${path.relative(__dirname, file)} not found, skipping`);
continue; continue;
} }
parts.push(fs.readFileSync(file, 'utf8')); // DC-119: normalize CRLF -> LF before minifying. Root cause (verified
// empirically with esbuild 0.25.12 probes): the production transform
// uses sourcemap:'both', which base64-embeds the RAW source bytes as
// sourcesContent in the inline map — CR bytes survive into dist, so a
// CRLF working copy (Windows dev tree, core.autocrlf=true) and an LF
// checkout (DNS2) of the same commit produce different dist bytes and
// a different sw.js cache tag. With this normalization both are
// byte-identical (sha256-verified). Before it, every Linux rebuild of
// a Windows-committed bundle showed phantom drift on `git pull` in
// /opt/dashcaddy (the recurring "pre-pull drift" stashes — note those
// also contained minified-identifier renames, a second vector from
// esbuild version drift across the ^0.25.0 caret range, already
// pinned by package-lock.json).
parts.push(normalizeSource(fs.readFileSync(file, 'utf8')));
} }
const concatenated = parts.join(';\n'); const concatenated = parts.join(';\n');
@@ -197,7 +216,11 @@ async function build() {
// the SW's activate handler wipes all older caches, so users never get // the SW's activate handler wipes all older caches, so users never get
// stuck on stale precached bundles after a release. // stuck on stale precached bundles after a release.
function updateServiceWorkerCache() { function updateServiceWorkerCache() {
const sw = fs.readFileSync(SW_JS, 'utf8'); // DC-119: normalize on read — same rationale as the bundle sources above.
// A CRLF sw.js would otherwise keep its CR bytes through the regex
// replace, so the written sw.js (and its committed bytes) would differ
// per-platform even with an identical cache tag.
const sw = normalizeSource(fs.readFileSync(SW_JS, 'utf8'));
const hash = crypto.createHash('sha256'); const hash = crypto.createHash('sha256');
for (const name of Object.keys(bundles)) { for (const name of Object.keys(bundles)) {
hash.update(fs.readFileSync(path.join(DIST, name))); hash.update(fs.readFileSync(path.join(DIST, name)));
@@ -214,20 +237,29 @@ function updateServiceWorkerCache() {
} }
// Watch mode // Watch mode
if (process.argv.includes('--watch')) { // DC-119: only auto-run when invoked directly (`node build.js`). Requiring
console.log(' Watching for changes...\n'); // build.js as a module (as tests/build-determinism.test.js does, to pin the
build(); // normalizeSource regex) must NOT trigger a full dist rebuild.
if (require.main === module) {
if (process.argv.includes('--watch')) {
console.log(' Watching for changes...\n');
build();
const jsDir = path.join(__dirname, 'js'); const jsDir = path.join(__dirname, 'js');
let debounce = null; let debounce = null;
fs.watch(jsDir, { recursive: true }, (event, filename) => { fs.watch(jsDir, { recursive: true }, (event, filename) => {
if (!filename || !filename.endsWith('.js')) return; if (!filename || !filename.endsWith('.js')) return;
clearTimeout(debounce); clearTimeout(debounce);
debounce = setTimeout(() => { debounce = setTimeout(() => {
console.log(` Changed: ${filename}`); console.log(` Changed: ${filename}`);
build(); build();
}, 200); }, 200);
}); });
} else { } else {
build(); build();
}
} }
// DC-119: export for tests (normalizeSource is pinned by
// tests/build-determinism.test.js). build/bundles stay internal.
module.exports = { normalizeSource };
+122 -95
View File
File diff suppressed because one or more lines are too long
+541 -1
View File
@@ -8,7 +8,194 @@
"name": "dashcaddy-frontend", "name": "dashcaddy-frontend",
"version": "1.0.0", "version": "1.0.0",
"devDependencies": { "devDependencies": {
"esbuild": "^0.25.0" "esbuild": "^0.25.0",
"jsdom": "^30.0.1"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz",
"integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.3.0",
"@csstools/css-color-parser": "^4.1.10",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz",
"integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz",
"integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.1",
"@csstools/css-calc": "^3.3.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz",
"integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
@@ -453,6 +640,97 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -494,6 +772,268 @@
"@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12" "@esbuild/win32-x64": "0.25.12"
} }
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "30.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz",
"integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^6.0.5",
"@asamuzakjp/dom-selector": "^8.3.0",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.7",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.2",
"undici": "^8.9.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tldts": {
"version": "7.4.10",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz",
"integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.10"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.10",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz",
"integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/undici": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
} }
} }
} }
+2 -1
View File
@@ -8,6 +8,7 @@
"watch": "node build.js --watch" "watch": "node build.js --watch"
}, },
"devDependencies": { "devDependencies": {
"esbuild": "^0.25.0" "esbuild": "^0.25.0",
"jsdom": "^30.0.1"
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-3354f5fd96'; const CACHE = 'dashcaddy-shell-d39ab69dd4';
const PRECACHE = [ const PRECACHE = [
'/', '/',
'/index.html', '/index.html',
+127
View File
@@ -0,0 +1,127 @@
'use strict';
/**
* DC-119: frontend build determinism across line endings.
*
* The frontend build (status/build.js) concatenates raw source files and
* minifies them with esbuild using sourcemap:'both' — the inline map
* base64-embeds the raw source bytes (sourcesContent), CRs included. A CRLF
* working copy (Windows dev tree, core.autocrlf=true) vs an LF working copy
* (DNS2 Linux checkout) of the SAME commit therefore produces different
* dist bytes and a different sw.js cache tag — so the committed dist could
* never be reproduced on the deploy host, showing up as permanent phantom
* drift on `git pull` in /opt/dashcaddy (the recurring "pre-pull drift"
* stashes).
*
* build.js now normalizes every source read to LF (\r\n -> \n) before
* concatenation. This test pins that behavior at the transform level: the
* SAME input, CRLF vs LF, must produce byte-identical minified output, and
* the normalization regex used by build.js must strip all CR bytes.
*
* It deliberately does NOT shell out to `node build.js` (slow, writes
* dist/) — it exercises the exact transform + normalization logic inline.
*/
const test = require('node:test');
const assert = require('node:assert');
// Same devDependency esbuild the build itself uses.
const esbuild = require('esbuild');
// DC-119: import THE ACTUAL normalization from build.js — not a local
// re-implementation — so this test fails if the build's regex ever changes.
const { normalizeSource: normalize } = require('../build.js');
// Representative source: top-level names, nested scopes, strings with
// escapes, template literals, regex literals, comments — the constructs
// whose minified renames shifted pre-fix.
const SAMPLE = `// feature module
const logoCustomization = {
position: 'left',
cacheTag: 'dashcaddy-shell-abc123',
};
function applyPosition(position, elem) {
const normalized = position || logoCustomization.position;
elem.setAttribute('data-logo-pos', normalized);
return normalized.replace(/_/g, ' ');
}
const summarize = (items) => {
let total = 0;
for (const item of items) {
total += item.count ?? 0;
}
return \`total: \${total} (\${items.length} items)\`;
};
async function loadConfig(url) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error('HTTP ' + res.status);
return await res.json();
} catch (err) {
console.warn('load failed:', err.message);
return null;
}
}
module.exports = { applyPosition, summarize, loadConfig };
`;
// Production transform options — MUST mirror build.js. The CRLF divergence
// lives in the INLINE SOURCEMAP: sourcemap:'both' embeds the raw source as
// base64 sourcesContent, so CRLF bytes survive into dist and shift both the
// bundle bytes and the sw.js content-hash cache tag.
async function minify(source) {
const { code } = await esbuild.transform(source, {
minify: true,
target: 'es2020',
sourcemap: 'both',
});
return code;
}
test('DC-119: CRLF and LF inputs produce byte-identical minified output', async () => {
const lf = SAMPLE;
const crlf = SAMPLE.replace(/\n/g, '\r\n');
// Sanity: the two raw inputs really do differ.
assert.notEqual(lf, crlf, 'fixture setup: CRLF variant must differ from LF');
const outLf = await minify(normalize(lf));
const outCrlf = await minify(normalize(crlf));
assert.strictEqual(
outCrlf,
outLf,
'minified output must be byte-identical after CRLF->LF normalization'
);
});
test('DC-119: without normalization, CRLF vs LF differ (documents the bug)', async () => {
const lf = SAMPLE;
const crlf = SAMPLE.replace(/\n/g, '\r\n');
const outLf = await minify(lf);
const outCrlf = await minify(crlf);
// Documents WHY the normalization exists: the inline sourcemap's
// sourcesContent base64-encodes the raw bytes, CRs included. If esbuild
// ever normalizes sourcesContent itself, this may flip to equal — then
// the normalization is redundant but harmless; update the DC-119 comment
// in build.js when that happens.
assert.notEqual(
outCrlf,
outLf,
'expected CRLF/LF divergence pre-normalization (inline sourcemap sourcesContent); if equal, esbuild changed behavior — update the DC-119 comment in build.js'
);
});
test('DC-119: normalization strips every CR from CRLF input and leaves LF untouched', () => {
const crlf = 'line1\r\nline2\r\n';
const lf = 'line1\nline2\n';
// Lone \r (old-Mac style) is NOT produced by git autocrlf and is NOT
// claimed to be handled — assert only the CRLF contract.
assert.strictEqual(normalize(crlf), 'line1\nline2\n');
assert.strictEqual(normalize(lf), 'line1\nline2\n');
assert.ok(!normalize(crlf).includes('\r'), 'no CR may survive normalization');
});