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
<FilesMatch "\.(woff2|woff|ttf|eot)$">
Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"
Header set Access-Control-Allow-Headers "Content-Type"
Header set Cache-Control "public, max-age=31536000"
# Proper MIME types
<IfModule mod_mime.c>
AddType font/woff2 .woff2
AddType font/woff .woff
AddType font/ttf .ttf
AddType application/vnd.ms-fontobject .eot
</IfModule>
</FilesMatch>
# Prevent direct access to font conversion scripts
<FilesMatch "\.(py|bat)$">
Order allow,deny
Deny from all
# Font file headers to prevent sanitizer issues
<FilesMatch "\.(woff2|woff|ttf|eot)$">
Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"
Header set Access-Control-Allow-Headers "Content-Type"
Header set Cache-Control "public, max-age=31536000"
# Proper MIME types
<IfModule mod_mime.c>
AddType font/woff2 .woff2
AddType font/woff .woff
AddType font/ttf .ttf
AddType application/vnd.ms-fontobject .eot
</IfModule>
</FilesMatch>
# Prevent direct access to font conversion scripts
<FilesMatch "\.(py|bat)$">
Order allow,deny
Deny from all
</FilesMatch>
+321 -321
View File
@@ -1,321 +1,321 @@
/**
* DNS Template Selector
* Presents DNS server template options when user chooses to set up DNS
*/
(function(window) {
'use strict';
class DnsTemplateSelector {
constructor(progressTracker) {
this.progressTracker = progressTracker;
this.modal = null;
this.onTemplateSelected = null;
console.log('[DnsTemplateSelector] Module loaded');
}
/**
* Get available DNS server templates from app templates
* @returns {Array} Array of DNS template objects
*/
getDnsTemplates() {
// In a real implementation, this would fetch from app-templates.js
// For now, return hardcoded templates matching what we added
return [
{
id: 'technitium',
name: 'Technitium DNS Server',
description: 'Modern DNS server with web UI for managing private zones',
icon: '🌐',
difficulty: 'Easy',
features: [
'Web-based management interface',
'Private zone management for .sami domain',
'DHCP server integration',
'DNS-over-HTTPS and DNS-over-TLS support'
],
recommended: true
},
{
id: 'bind9',
name: 'BIND9 DNS Server',
description: 'Industry-standard DNS server - powerful and flexible',
icon: '🔧',
difficulty: 'Advanced',
features: [
'Industry standard DNS server',
'Full RFC compliance',
'Advanced zone management',
'DNSSEC support'
],
recommended: false
},
{
id: 'pihole',
name: 'Pi-hole',
description: 'Network-wide ad blocker with DNS capabilities',
icon: '🛡️',
difficulty: 'Intermediate',
features: [
'Ad blocking at DNS level',
'Web interface for management',
'DHCP server included',
'Query logging and statistics'
],
recommended: false
},
{
id: 'powerdns',
name: 'PowerDNS',
description: 'High-performance DNS server with SQL backend',
icon: '⚡',
difficulty: 'Intermediate',
features: [
'SQL database backend',
'RESTful API for automation',
'Geographic load balancing',
'DNSSEC support'
],
recommended: false
},
{
id: 'coredns',
name: 'CoreDNS',
description: 'Cloud-native DNS server - lightweight and flexible',
icon: '☁️',
difficulty: 'Intermediate',
features: [
'Plugin-based architecture',
'Kubernetes-native',
'Lightweight and fast',
'Prometheus metrics'
],
recommended: false
}
];
}
/**
* Show DNS template selection modal
*/
showTemplateSelector() {
// Create modal if it doesn't exist
if (!this.modal) {
this.createModal();
}
// Populate with templates
this.populateTemplates();
// Show modal
this.modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
/**
* Create the modal HTML structure
* @private
*/
createModal() {
const modal = document.createElement('div');
modal.id = 'dns-template-modal';
modal.className = 'dns-template-modal';
modal.innerHTML = `
<div class="dns-template-modal-content">
<div class="dns-template-header">
<h2>🌐 Choose a DNS Server</h2>
<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>
</div>
<div class="dns-template-grid" id="dns-template-grid">
<!-- Templates will be inserted here -->
</div>
<div class="dns-template-footer">
<button class="dns-template-later-btn" id="dns-setup-later">Set up later</button>
</div>
</div>
`;
document.body.appendChild(modal);
this.modal = modal;
// Add event listeners
modal.querySelector('.dns-template-close').addEventListener('click', () => this.close());
modal.querySelector('#dns-setup-later').addEventListener('click', () => this.handleSetupLater());
// Close on overlay click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.close();
}
});
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.style.display === 'flex') {
this.close();
}
});
}
/**
* Populate modal with DNS templates
* @private
*/
populateTemplates() {
const grid = document.getElementById('dns-template-grid');
if (!grid) return;
const templates = this.getDnsTemplates();
grid.innerHTML = '';
templates.forEach(template => {
const card = this.createTemplateCard(template);
grid.appendChild(card);
});
}
/**
* Create a template card element
* @private
*/
createTemplateCard(template) {
const card = document.createElement('div');
card.className = 'dns-template-card';
if (template.recommended) {
card.classList.add('recommended');
}
const difficultyClass = template.difficulty.toLowerCase();
card.innerHTML = `
${template.recommended ? '<div class="recommended-badge">Recommended</div>' : ''}
<div class="dns-template-icon">${template.icon}</div>
<h3>${template.name}</h3>
<p class="dns-template-description">${template.description}</p>
<div class="dns-template-difficulty difficulty-${difficultyClass}">
${template.difficulty}
</div>
<ul class="dns-template-features">
${template.features.slice(0, 3).map(f => `<li>${f}</li>`).join('')}
</ul>
<button class="dns-template-select-btn" data-template-id="${template.id}">
Select ${template.name}
</button>
`;
// Add click handler to select button
const selectBtn = card.querySelector('.dns-template-select-btn');
selectBtn.addEventListener('click', () => this.handleTemplateSelection(template));
return card;
}
/**
* Handle template selection
* @private
*/
handleTemplateSelection(template) {
console.log(`[DnsTemplateSelector] Template selected: ${template.id}`);
// Close modal
this.close();
// Trigger callback if set
if (this.onTemplateSelected) {
this.onTemplateSelected(template);
} else {
// Default behavior: open app selector with DNS filter
this.openAppSelector(template.id);
}
}
/**
* Handle "Set up later" button
* @private
*/
handleSetupLater() {
console.log('[DnsTemplateSelector] DNS setup deferred');
// Mark as deferred in progress tracker
if (this.progressTracker) {
this.progressTracker.markDnsSetupDeferred();
}
// Close modal
this.close();
// Show notification
this.showNotification('DNS setup deferred. You can set it up later from the App Selector.');
}
/**
* Open app selector with specific template
* @private
*/
openAppSelector(templateId) {
// Try to open the app selector modal if it exists
const appSelectorBtn = document.querySelector('[onclick*="showAppSelector"]');
if (appSelectorBtn) {
appSelectorBtn.click();
// Wait a bit then filter to the selected template
setTimeout(() => {
const searchInput = document.querySelector('#app-search');
if (searchInput) {
searchInput.value = templateId;
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}, 300);
} else {
// Fallback: show instructions
this.showNotification(`To deploy ${templateId}, use the App Selector and search for "${templateId}"`);
}
}
/**
* Show notification message
* @private
*/
showNotification(message) {
// Simple notification - could be enhanced
const notification = document.createElement('div');
notification.className = 'dns-template-notification';
notification.textContent = message;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: var(--card-base);
color: var(--fg);
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 10001;
max-width: 300px;
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transition = 'opacity 0.3s';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
/**
* Close the modal
*/
close() {
if (this.modal) {
this.modal.style.display = 'none';
document.body.style.overflow = '';
}
}
}
window.DnsTemplateSelector = DnsTemplateSelector;
console.log('[DnsTemplateSelector] Module loaded');
})(window);
/**
* DNS Template Selector
* Presents DNS server template options when user chooses to set up DNS
*/
(function(window) {
'use strict';
class DnsTemplateSelector {
constructor(progressTracker) {
this.progressTracker = progressTracker;
this.modal = null;
this.onTemplateSelected = null;
console.log('[DnsTemplateSelector] Module loaded');
}
/**
* Get available DNS server templates from app templates
* @returns {Array} Array of DNS template objects
*/
getDnsTemplates() {
// In a real implementation, this would fetch from app-templates.js
// For now, return hardcoded templates matching what we added
return [
{
id: 'technitium',
name: 'Technitium DNS Server',
description: 'Modern DNS server with web UI for managing private zones',
icon: '🌐',
difficulty: 'Easy',
features: [
'Web-based management interface',
'Private zone management for .sami domain',
'DHCP server integration',
'DNS-over-HTTPS and DNS-over-TLS support'
],
recommended: true
},
{
id: 'bind9',
name: 'BIND9 DNS Server',
description: 'Industry-standard DNS server - powerful and flexible',
icon: '🔧',
difficulty: 'Advanced',
features: [
'Industry standard DNS server',
'Full RFC compliance',
'Advanced zone management',
'DNSSEC support'
],
recommended: false
},
{
id: 'pihole',
name: 'Pi-hole',
description: 'Network-wide ad blocker with DNS capabilities',
icon: '🛡️',
difficulty: 'Intermediate',
features: [
'Ad blocking at DNS level',
'Web interface for management',
'DHCP server included',
'Query logging and statistics'
],
recommended: false
},
{
id: 'powerdns',
name: 'PowerDNS',
description: 'High-performance DNS server with SQL backend',
icon: '⚡',
difficulty: 'Intermediate',
features: [
'SQL database backend',
'RESTful API for automation',
'Geographic load balancing',
'DNSSEC support'
],
recommended: false
},
{
id: 'coredns',
name: 'CoreDNS',
description: 'Cloud-native DNS server - lightweight and flexible',
icon: '☁️',
difficulty: 'Intermediate',
features: [
'Plugin-based architecture',
'Kubernetes-native',
'Lightweight and fast',
'Prometheus metrics'
],
recommended: false
}
];
}
/**
* Show DNS template selection modal
*/
showTemplateSelector() {
// Create modal if it doesn't exist
if (!this.modal) {
this.createModal();
}
// Populate with templates
this.populateTemplates();
// Show modal
this.modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
/**
* Create the modal HTML structure
* @private
*/
createModal() {
const modal = document.createElement('div');
modal.id = 'dns-template-modal';
modal.className = 'dns-template-modal';
modal.innerHTML = `
<div class="dns-template-modal-content">
<div class="dns-template-header">
<h2>🌐 Choose a DNS Server</h2>
<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>
</div>
<div class="dns-template-grid" id="dns-template-grid">
<!-- Templates will be inserted here -->
</div>
<div class="dns-template-footer">
<button class="dns-template-later-btn" id="dns-setup-later">Set up later</button>
</div>
</div>
`;
document.body.appendChild(modal);
this.modal = modal;
// Add event listeners
modal.querySelector('.dns-template-close').addEventListener('click', () => this.close());
modal.querySelector('#dns-setup-later').addEventListener('click', () => this.handleSetupLater());
// Close on overlay click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.close();
}
});
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.style.display === 'flex') {
this.close();
}
});
}
/**
* Populate modal with DNS templates
* @private
*/
populateTemplates() {
const grid = document.getElementById('dns-template-grid');
if (!grid) return;
const templates = this.getDnsTemplates();
grid.innerHTML = '';
templates.forEach(template => {
const card = this.createTemplateCard(template);
grid.appendChild(card);
});
}
/**
* Create a template card element
* @private
*/
createTemplateCard(template) {
const card = document.createElement('div');
card.className = 'dns-template-card';
if (template.recommended) {
card.classList.add('recommended');
}
const difficultyClass = template.difficulty.toLowerCase();
card.innerHTML = `
${template.recommended ? '<div class="recommended-badge">Recommended</div>' : ''}
<div class="dns-template-icon">${template.icon}</div>
<h3>${template.name}</h3>
<p class="dns-template-description">${template.description}</p>
<div class="dns-template-difficulty difficulty-${difficultyClass}">
${template.difficulty}
</div>
<ul class="dns-template-features">
${template.features.slice(0, 3).map(f => `<li>${f}</li>`).join('')}
</ul>
<button class="dns-template-select-btn" data-template-id="${template.id}">
Select ${template.name}
</button>
`;
// Add click handler to select button
const selectBtn = card.querySelector('.dns-template-select-btn');
selectBtn.addEventListener('click', () => this.handleTemplateSelection(template));
return card;
}
/**
* Handle template selection
* @private
*/
handleTemplateSelection(template) {
console.log(`[DnsTemplateSelector] Template selected: ${template.id}`);
// Close modal
this.close();
// Trigger callback if set
if (this.onTemplateSelected) {
this.onTemplateSelected(template);
} else {
// Default behavior: open app selector with DNS filter
this.openAppSelector(template.id);
}
}
/**
* Handle "Set up later" button
* @private
*/
handleSetupLater() {
console.log('[DnsTemplateSelector] DNS setup deferred');
// Mark as deferred in progress tracker
if (this.progressTracker) {
this.progressTracker.markDnsSetupDeferred();
}
// Close modal
this.close();
// Show notification
this.showNotification('DNS setup deferred. You can set it up later from the App Selector.');
}
/**
* Open app selector with specific template
* @private
*/
openAppSelector(templateId) {
// Try to open the app selector modal if it exists
const appSelectorBtn = document.querySelector('[onclick*="showAppSelector"]');
if (appSelectorBtn) {
appSelectorBtn.click();
// Wait a bit then filter to the selected template
setTimeout(() => {
const searchInput = document.querySelector('#app-search');
if (searchInput) {
searchInput.value = templateId;
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}, 300);
} else {
// Fallback: show instructions
this.showNotification(`To deploy ${templateId}, use the App Selector and search for "${templateId}"`);
}
}
/**
* Show notification message
* @private
*/
showNotification(message) {
// Simple notification - could be enhanced
const notification = document.createElement('div');
notification.className = 'dns-template-notification';
notification.textContent = message;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: var(--card-base);
color: var(--fg);
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 10001;
max-width: 300px;
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transition = 'opacity 0.3s';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
/**
* Close the modal
*/
close() {
if (this.modal) {
this.modal.style.display = 'none';
document.body.style.overflow = '';
}
}
}
window.DnsTemplateSelector = DnsTemplateSelector;
console.log('[DnsTemplateSelector] Module loaded');
})(window);
+259 -259
View File
@@ -1,259 +1,259 @@
/**
* Error Handler
* Handles errors gracefully without breaking the onboarding tour
*/
(function(window) {
'use strict';
class ErrorHandler {
constructor() {
this.errors = [];
this.maxErrors = 50; // Keep last 50 errors
}
/**
* Log an error without breaking the tour
* @param {string} context - Context where error occurred
* @param {Error|string} error - The error object or message
* @param {Object} metadata - Additional metadata
*/
logError(context, error, metadata = {}) {
const errorEntry = {
timestamp: new Date().toISOString(),
context,
message: error instanceof Error ? error.message : error,
stack: error instanceof Error ? error.stack : null,
metadata
};
// Add to errors array
this.errors.push(errorEntry);
// Keep only last maxErrors
if (this.errors.length > this.maxErrors) {
this.errors.shift();
}
// Log to console
console.error(`[Onboarding Error] ${context}:`, error, metadata);
// Optionally send to error tracking service
// this.sendToErrorTracking(errorEntry);
}
/**
* Attempt to recover from an error and continue tour
* @param {Error} error - The error object
* @param {number} currentStep - Current step index
* @returns {Object} Recovery action
*/
recoverFromError(error, currentStep) {
const errorType = this.classifyError(error);
switch (errorType) {
case 'ELEMENT_NOT_FOUND':
this.logError('Element Not Found', error, { currentStep });
return {
action: 'SKIP_STEP',
nextStep: currentStep + 1,
message: 'Target element not found, skipping to next step'
};
case 'STORAGE_UNAVAILABLE':
this.logError('Storage Unavailable', error);
return {
action: 'USE_MEMORY_STORAGE',
message: 'Local storage unavailable, using in-memory storage'
};
case 'DRIVER_NOT_LOADED':
this.logError('Driver.js Not Loaded', error);
return {
action: 'ABORT_TOUR',
message: 'Driver.js library not loaded, cannot start tour'
};
case 'INVALID_TOOLTIP':
this.logError('Invalid Tooltip Configuration', error, { currentStep });
return {
action: 'SKIP_STEP',
nextStep: currentStep + 1,
message: 'Invalid tooltip configuration, skipping'
};
case 'THEME_DETECTION_FAILED':
this.logError('Theme Detection Failed', error);
return {
action: 'USE_DEFAULT_THEME',
message: 'Using default dark theme'
};
default:
this.logError('Unknown Error', error, { currentStep });
return {
action: 'ABORT_TOUR',
message: 'Unexpected error occurred, aborting tour'
};
}
}
/**
* Classify error type
* @private
* @param {Error} error - The error object
* @returns {string} Error type
*/
classifyError(error) {
const message = error.message || error.toString();
if (message.includes('element') && message.includes('not found')) {
return 'ELEMENT_NOT_FOUND';
}
if (message.includes('storage') || message.includes('quota')) {
return 'STORAGE_UNAVAILABLE';
}
if (message.includes('driver') || message.includes('undefined')) {
return 'DRIVER_NOT_LOADED';
}
if (message.includes('invalid') || message.includes('validation')) {
return 'INVALID_TOOLTIP';
}
if (message.includes('theme')) {
return 'THEME_DETECTION_FAILED';
}
return 'UNKNOWN';
}
/**
* Get all logged errors
* @returns {Array} Array of error entries
*/
getErrors() {
return [...this.errors];
}
/**
* Clear all logged errors
*/
clearErrors() {
this.errors = [];
}
/**
* Get error statistics
* @returns {Object} Error statistics
*/
getStatistics() {
const stats = {
total: this.errors.length,
byContext: {},
byType: {},
recent: this.errors.slice(-10)
};
this.errors.forEach(error => {
// Count by context
stats.byContext[error.context] = (stats.byContext[error.context] || 0) + 1;
// Count by type
const type = this.classifyError({ message: error.message });
stats.byType[type] = (stats.byType[type] || 0) + 1;
});
return stats;
}
/**
* Handle graceful degradation when Driver.js fails to load
* @returns {boolean} Whether fallback was successful
*/
handleDriverLoadFailure() {
this.logError('Driver.js Load Failure', 'Driver.js library failed to load');
// Show fallback message
const fallbackMessage = document.createElement('div');
fallbackMessage.id = 'onboarding-fallback';
fallbackMessage.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: var(--card-base, #2a2a2a);
color: var(--fg, #ffffff);
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 9999;
max-width: 300px;
font-size: 14px;
`;
fallbackMessage.innerHTML = `
<strong>Welcome to DashCaddy!</strong><br>
<p style="margin: 10px 0 0 0; font-size: 12px;">
The interactive tour is unavailable, but you can explore the dashboard freely.
Check the documentation for help getting started.
</p>
`;
document.body.appendChild(fallbackMessage);
// Auto-remove after 10 seconds
setTimeout(() => {
if (fallbackMessage.parentNode) {
fallbackMessage.parentNode.removeChild(fallbackMessage);
}
}, 10000);
return true;
}
/**
* Handle storage unavailable scenario
* @returns {Object} In-memory storage fallback
*/
handleStorageUnavailable() {
this.logError('Storage Unavailable', 'Local storage is not available');
// Create in-memory storage
const memoryStorage = {
data: {},
getItem(key) {
return this.data[key] || null;
},
setItem(key, value) {
this.data[key] = value;
},
removeItem(key) {
delete this.data[key];
},
clear() {
this.data = {};
}
};
console.warn('[ErrorHandler] Using in-memory storage - progress will not persist');
return memoryStorage;
}
/**
* Send error to tracking service (placeholder)
* @private
* @param {Object} errorEntry - Error entry to send
*/
sendToErrorTracking(errorEntry) {
// Placeholder for error tracking integration
// Could integrate with Sentry, LogRocket, etc.
// Example:
// if (window.Sentry) {
// Sentry.captureException(new Error(errorEntry.message), {
// extra: errorEntry.metadata
// });
// }
}
}
window.ErrorHandler = ErrorHandler;
console.log('[ErrorHandler] Module loaded');
})(window);
/**
* Error Handler
* Handles errors gracefully without breaking the onboarding tour
*/
(function(window) {
'use strict';
class ErrorHandler {
constructor() {
this.errors = [];
this.maxErrors = 50; // Keep last 50 errors
}
/**
* Log an error without breaking the tour
* @param {string} context - Context where error occurred
* @param {Error|string} error - The error object or message
* @param {Object} metadata - Additional metadata
*/
logError(context, error, metadata = {}) {
const errorEntry = {
timestamp: new Date().toISOString(),
context,
message: error instanceof Error ? error.message : error,
stack: error instanceof Error ? error.stack : null,
metadata
};
// Add to errors array
this.errors.push(errorEntry);
// Keep only last maxErrors
if (this.errors.length > this.maxErrors) {
this.errors.shift();
}
// Log to console
console.error(`[Onboarding Error] ${context}:`, error, metadata);
// Optionally send to error tracking service
// this.sendToErrorTracking(errorEntry);
}
/**
* Attempt to recover from an error and continue tour
* @param {Error} error - The error object
* @param {number} currentStep - Current step index
* @returns {Object} Recovery action
*/
recoverFromError(error, currentStep) {
const errorType = this.classifyError(error);
switch (errorType) {
case 'ELEMENT_NOT_FOUND':
this.logError('Element Not Found', error, { currentStep });
return {
action: 'SKIP_STEP',
nextStep: currentStep + 1,
message: 'Target element not found, skipping to next step'
};
case 'STORAGE_UNAVAILABLE':
this.logError('Storage Unavailable', error);
return {
action: 'USE_MEMORY_STORAGE',
message: 'Local storage unavailable, using in-memory storage'
};
case 'DRIVER_NOT_LOADED':
this.logError('Driver.js Not Loaded', error);
return {
action: 'ABORT_TOUR',
message: 'Driver.js library not loaded, cannot start tour'
};
case 'INVALID_TOOLTIP':
this.logError('Invalid Tooltip Configuration', error, { currentStep });
return {
action: 'SKIP_STEP',
nextStep: currentStep + 1,
message: 'Invalid tooltip configuration, skipping'
};
case 'THEME_DETECTION_FAILED':
this.logError('Theme Detection Failed', error);
return {
action: 'USE_DEFAULT_THEME',
message: 'Using default dark theme'
};
default:
this.logError('Unknown Error', error, { currentStep });
return {
action: 'ABORT_TOUR',
message: 'Unexpected error occurred, aborting tour'
};
}
}
/**
* Classify error type
* @private
* @param {Error} error - The error object
* @returns {string} Error type
*/
classifyError(error) {
const message = error.message || error.toString();
if (message.includes('element') && message.includes('not found')) {
return 'ELEMENT_NOT_FOUND';
}
if (message.includes('storage') || message.includes('quota')) {
return 'STORAGE_UNAVAILABLE';
}
if (message.includes('driver') || message.includes('undefined')) {
return 'DRIVER_NOT_LOADED';
}
if (message.includes('invalid') || message.includes('validation')) {
return 'INVALID_TOOLTIP';
}
if (message.includes('theme')) {
return 'THEME_DETECTION_FAILED';
}
return 'UNKNOWN';
}
/**
* Get all logged errors
* @returns {Array} Array of error entries
*/
getErrors() {
return [...this.errors];
}
/**
* Clear all logged errors
*/
clearErrors() {
this.errors = [];
}
/**
* Get error statistics
* @returns {Object} Error statistics
*/
getStatistics() {
const stats = {
total: this.errors.length,
byContext: {},
byType: {},
recent: this.errors.slice(-10)
};
this.errors.forEach(error => {
// Count by context
stats.byContext[error.context] = (stats.byContext[error.context] || 0) + 1;
// Count by type
const type = this.classifyError({ message: error.message });
stats.byType[type] = (stats.byType[type] || 0) + 1;
});
return stats;
}
/**
* Handle graceful degradation when Driver.js fails to load
* @returns {boolean} Whether fallback was successful
*/
handleDriverLoadFailure() {
this.logError('Driver.js Load Failure', 'Driver.js library failed to load');
// Show fallback message
const fallbackMessage = document.createElement('div');
fallbackMessage.id = 'onboarding-fallback';
fallbackMessage.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: var(--card-base, #2a2a2a);
color: var(--fg, #ffffff);
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 9999;
max-width: 300px;
font-size: 14px;
`;
fallbackMessage.innerHTML = `
<strong>Welcome to DashCaddy!</strong><br>
<p style="margin: 10px 0 0 0; font-size: 12px;">
The interactive tour is unavailable, but you can explore the dashboard freely.
Check the documentation for help getting started.
</p>
`;
document.body.appendChild(fallbackMessage);
// Auto-remove after 10 seconds
setTimeout(() => {
if (fallbackMessage.parentNode) {
fallbackMessage.parentNode.removeChild(fallbackMessage);
}
}, 10000);
return true;
}
/**
* Handle storage unavailable scenario
* @returns {Object} In-memory storage fallback
*/
handleStorageUnavailable() {
this.logError('Storage Unavailable', 'Local storage is not available');
// Create in-memory storage
const memoryStorage = {
data: {},
getItem(key) {
return this.data[key] || null;
},
setItem(key, value) {
this.data[key] = value;
},
removeItem(key) {
delete this.data[key];
},
clear() {
this.data = {};
}
};
console.warn('[ErrorHandler] Using in-memory storage - progress will not persist');
return memoryStorage;
}
/**
* Send error to tracking service (placeholder)
* @private
* @param {Object} errorEntry - Error entry to send
*/
sendToErrorTracking(errorEntry) {
// Placeholder for error tracking integration
// Could integrate with Sentry, LogRocket, etc.
// Example:
// if (window.Sentry) {
// Sentry.captureException(new Error(errorEntry.message), {
// extra: errorEntry.metadata
// });
// }
}
}
window.ErrorHandler = ErrorHandler;
console.log('[ErrorHandler] Module loaded');
})(window);
+91 -91
View File
@@ -1,91 +1,91 @@
/* Sami Sans Font Family - External CSS */
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Regular.woff2') format('woff2'),
url('fonts/SamiSans-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Regular.woff2') format('woff2'),
url('fonts/SamiSans-Italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Medium.woff2') format('woff2'),
url('fonts/SamiSans-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-SemiBold.woff2') format('woff2'),
url('fonts/SamiSans-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Bold.woff2') format('woff2'),
url('fonts/SamiSans-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-ExtraBold.woff2') format('woff2'),
url('fonts/SamiSans-ExtraBold.ttf') format('truetype');
font-weight: 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Black.woff2') format('woff2'),
url('fonts/SamiSans-Black.ttf') format('truetype');
font-weight: 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Light.woff2') format('woff2'),
url('fonts/SamiSans-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-ExtraLight.woff2') format('woff2'),
url('fonts/SamiSans-ExtraLight.ttf') format('truetype');
font-weight: 200;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Thin.woff2') format('woff2'),
url('fonts/SamiSans-Thin.ttf') format('truetype');
font-weight: 100;
font-style: normal;
font-display: swap;
}
/* Sami Sans Font Family - External CSS */
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Regular.woff2') format('woff2'),
url('fonts/SamiSans-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Regular.woff2') format('woff2'),
url('fonts/SamiSans-Italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Medium.woff2') format('woff2'),
url('fonts/SamiSans-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-SemiBold.woff2') format('woff2'),
url('fonts/SamiSans-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Bold.woff2') format('woff2'),
url('fonts/SamiSans-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-ExtraBold.woff2') format('woff2'),
url('fonts/SamiSans-ExtraBold.ttf') format('truetype');
font-weight: 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Black.woff2') format('woff2'),
url('fonts/SamiSans-Black.ttf') format('truetype');
font-weight: 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Light.woff2') format('woff2'),
url('fonts/SamiSans-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-ExtraLight.woff2') format('woff2'),
url('fonts/SamiSans-ExtraLight.ttf') format('truetype');
font-weight: 200;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Thin.woff2') format('woff2'),
url('fonts/SamiSans-Thin.ttf') format('truetype');
font-weight: 100;
font-style: normal;
font-display: swap;
}
+354 -354
View File
@@ -1,354 +1,354 @@
/**
* Onboarding Tooltip Styles
* Custom styling for Driver.js tooltips to match DashCaddy theme
*/
/* Driver.js overrides are injected dynamically by ThemeAdapter */
/* This file contains additional custom styles */
.driver-popover {
max-width: 500px !important;
z-index: 10000 !important;
}
.driver-popover-title {
font-size: 1.2rem !important;
margin-bottom: 12px !important;
}
.driver-popover-description {
font-size: 0.95rem !important;
line-height: 1.6 !important;
}
.driver-popover-description p {
margin: 8px 0 !important;
}
.driver-popover-description ul {
margin: 8px 0 !important;
padding-left: 20px !important;
}
.driver-popover-description li {
margin: 4px 0 !important;
}
.driver-popover-description code {
background: rgba(0, 0, 0, 0.1) !important;
padding: 2px 6px !important;
border-radius: 3px !important;
font-family: 'Courier New', monospace !important;
font-size: 0.9em !important;
}
.driver-popover-footer {
margin-top: 16px !important;
display: flex !important;
gap: 8px !important;
justify-content: flex-end !important;
}
.driver-popover-footer button {
padding: 8px 16px !important;
border-radius: 8px !important;
font-size: 0.9rem !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
}
.driver-popover-footer button:hover {
transform: translateY(-1px) !important;
}
.driver-popover-close-btn {
position: absolute !important;
top: 12px !important;
right: 12px !important;
width: 24px !important;
height: 24px !important;
border-radius: 50% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer !important;
opacity: 0.6 !important;
transition: opacity 0.2s ease !important;
}
.driver-popover-close-btn:hover {
opacity: 1 !important;
}
.driver-popover-arrow {
border-width: 8px !important;
}
/* Progress indicator */
.driver-popover-progress-text {
font-size: 0.85rem !important;
margin-bottom: 8px !important;
}
/* Mobile responsive */
@media (max-width: 768px) {
.driver-popover {
max-width: calc(100vw - 32px) !important;
}
.driver-popover-title {
font-size: 1.1rem !important;
}
.driver-popover-description {
font-size: 0.9rem !important;
}
.driver-popover-footer button {
padding: 6px 12px !important;
font-size: 0.85rem !important;
}
}
/* Restart tour button in dashboard */
#restart-tour-btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
#restart-tour-btn::before {
content: "🎓";
font-size: 1.1em;
}
/* DNS Template Selector Modal */
.dns-template-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 10000;
align-items: center;
justify-content: center;
padding: 20px;
}
.dns-template-modal-content {
background: var(--card-base);
border-radius: 12px;
max-width: 900px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.dns-template-header {
padding: 30px;
border-bottom: 1px solid var(--border);
position: relative;
}
.dns-template-header h2 {
margin: 0 0 10px 0;
color: var(--fg);
font-size: 28px;
}
.dns-template-header p {
margin: 0;
color: var(--fg-muted);
font-size: 14px;
}
.dns-template-close {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
font-size: 32px;
color: var(--fg-muted);
cursor: pointer;
padding: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s;
}
.dns-template-close:hover {
background: var(--hover);
color: var(--fg);
}
.dns-template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
padding: 30px;
}
.dns-template-card {
background: var(--card-hover);
border: 2px solid var(--border);
border-radius: 12px;
padding: 20px;
transition: all 0.3s;
position: relative;
display: flex;
flex-direction: column;
}
.dns-template-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
border-color: var(--accent);
}
.dns-template-card.recommended {
border-color: var(--accent);
background: linear-gradient(135deg, var(--card-hover) 0%, var(--card-base) 100%);
}
.recommended-badge {
position: absolute;
top: -10px;
right: 20px;
background: var(--accent);
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.dns-template-icon {
font-size: 48px;
margin-bottom: 15px;
text-align: center;
}
.dns-template-card h3 {
margin: 0 0 10px 0;
color: var(--fg);
font-size: 18px;
text-align: center;
}
.dns-template-description {
color: var(--fg-muted);
font-size: 13px;
margin: 0 0 15px 0;
text-align: center;
flex-grow: 1;
}
.dns-template-difficulty {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
text-align: center;
margin: 0 auto 15px auto;
}
.difficulty-easy {
background: #2ecc71;
color: white;
}
.difficulty-intermediate {
background: #f39c12;
color: white;
}
.difficulty-advanced {
background: #e74c3c;
color: white;
}
.dns-template-features {
list-style: none;
padding: 0;
margin: 0 0 20px 0;
font-size: 12px;
color: var(--fg-muted);
}
.dns-template-features li {
padding: 6px 0;
padding-left: 20px;
position: relative;
}
.dns-template-features li:before {
content: "✓";
position: absolute;
left: 0;
color: var(--accent);
font-weight: bold;
}
.dns-template-select-btn {
background: var(--accent);
color: white;
border: none;
padding: 12px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
width: 100%;
}
.dns-template-select-btn:hover {
background: var(--accent-strong);
transform: scale(1.02);
}
.dns-template-footer {
padding: 20px 30px;
border-top: 1px solid var(--border);
text-align: center;
}
.dns-template-later-btn {
background: transparent;
color: var(--fg-muted);
border: 1px solid var(--border);
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.dns-template-later-btn:hover {
background: var(--hover);
color: var(--fg);
border-color: var(--fg-muted);
}
/* Responsive design */
@media (max-width: 768px) {
.dns-template-grid {
grid-template-columns: 1fr;
}
.dns-template-modal-content {
max-height: 95vh;
}
}
/**
* Onboarding Tooltip Styles
* Custom styling for Driver.js tooltips to match DashCaddy theme
*/
/* Driver.js overrides are injected dynamically by ThemeAdapter */
/* This file contains additional custom styles */
.driver-popover {
max-width: 500px !important;
z-index: 10000 !important;
}
.driver-popover-title {
font-size: 1.2rem !important;
margin-bottom: 12px !important;
}
.driver-popover-description {
font-size: 0.95rem !important;
line-height: 1.6 !important;
}
.driver-popover-description p {
margin: 8px 0 !important;
}
.driver-popover-description ul {
margin: 8px 0 !important;
padding-left: 20px !important;
}
.driver-popover-description li {
margin: 4px 0 !important;
}
.driver-popover-description code {
background: rgba(0, 0, 0, 0.1) !important;
padding: 2px 6px !important;
border-radius: 3px !important;
font-family: 'Courier New', monospace !important;
font-size: 0.9em !important;
}
.driver-popover-footer {
margin-top: 16px !important;
display: flex !important;
gap: 8px !important;
justify-content: flex-end !important;
}
.driver-popover-footer button {
padding: 8px 16px !important;
border-radius: 8px !important;
font-size: 0.9rem !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
}
.driver-popover-footer button:hover {
transform: translateY(-1px) !important;
}
.driver-popover-close-btn {
position: absolute !important;
top: 12px !important;
right: 12px !important;
width: 24px !important;
height: 24px !important;
border-radius: 50% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer !important;
opacity: 0.6 !important;
transition: opacity 0.2s ease !important;
}
.driver-popover-close-btn:hover {
opacity: 1 !important;
}
.driver-popover-arrow {
border-width: 8px !important;
}
/* Progress indicator */
.driver-popover-progress-text {
font-size: 0.85rem !important;
margin-bottom: 8px !important;
}
/* Mobile responsive */
@media (max-width: 768px) {
.driver-popover {
max-width: calc(100vw - 32px) !important;
}
.driver-popover-title {
font-size: 1.1rem !important;
}
.driver-popover-description {
font-size: 0.9rem !important;
}
.driver-popover-footer button {
padding: 6px 12px !important;
font-size: 0.85rem !important;
}
}
/* Restart tour button in dashboard */
#restart-tour-btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
#restart-tour-btn::before {
content: "🎓";
font-size: 1.1em;
}
/* DNS Template Selector Modal */
.dns-template-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 10000;
align-items: center;
justify-content: center;
padding: 20px;
}
.dns-template-modal-content {
background: var(--card-base);
border-radius: 12px;
max-width: 900px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.dns-template-header {
padding: 30px;
border-bottom: 1px solid var(--border);
position: relative;
}
.dns-template-header h2 {
margin: 0 0 10px 0;
color: var(--fg);
font-size: 28px;
}
.dns-template-header p {
margin: 0;
color: var(--fg-muted);
font-size: 14px;
}
.dns-template-close {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
font-size: 32px;
color: var(--fg-muted);
cursor: pointer;
padding: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s;
}
.dns-template-close:hover {
background: var(--hover);
color: var(--fg);
}
.dns-template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
padding: 30px;
}
.dns-template-card {
background: var(--card-hover);
border: 2px solid var(--border);
border-radius: 12px;
padding: 20px;
transition: all 0.3s;
position: relative;
display: flex;
flex-direction: column;
}
.dns-template-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
border-color: var(--accent);
}
.dns-template-card.recommended {
border-color: var(--accent);
background: linear-gradient(135deg, var(--card-hover) 0%, var(--card-base) 100%);
}
.recommended-badge {
position: absolute;
top: -10px;
right: 20px;
background: var(--accent);
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.dns-template-icon {
font-size: 48px;
margin-bottom: 15px;
text-align: center;
}
.dns-template-card h3 {
margin: 0 0 10px 0;
color: var(--fg);
font-size: 18px;
text-align: center;
}
.dns-template-description {
color: var(--fg-muted);
font-size: 13px;
margin: 0 0 15px 0;
text-align: center;
flex-grow: 1;
}
.dns-template-difficulty {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
text-align: center;
margin: 0 auto 15px auto;
}
.difficulty-easy {
background: #2ecc71;
color: white;
}
.difficulty-intermediate {
background: #f39c12;
color: white;
}
.difficulty-advanced {
background: #e74c3c;
color: white;
}
.dns-template-features {
list-style: none;
padding: 0;
margin: 0 0 20px 0;
font-size: 12px;
color: var(--fg-muted);
}
.dns-template-features li {
padding: 6px 0;
padding-left: 20px;
position: relative;
}
.dns-template-features li:before {
content: "✓";
position: absolute;
left: 0;
color: var(--accent);
font-weight: bold;
}
.dns-template-select-btn {
background: var(--accent);
color: white;
border: none;
padding: 12px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
width: 100%;
}
.dns-template-select-btn:hover {
background: var(--accent-strong);
transform: scale(1.02);
}
.dns-template-footer {
padding: 20px 30px;
border-top: 1px solid var(--border);
text-align: center;
}
.dns-template-later-btn {
background: transparent;
color: var(--fg-muted);
border: 1px solid var(--border);
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.dns-template-later-btn:hover {
background: var(--hover);
color: var(--fg);
border-color: var(--fg-muted);
}
/* Responsive design */
@media (max-width: 768px) {
.dns-template-grid {
grid-template-columns: 1fr;
}
.dns-template-modal-content {
max-height: 95vh;
}
}
+177 -177
View File
@@ -1,177 +1,177 @@
/**
* DashCaddy User Onboarding System
* Main entry point for the tooltip-based onboarding experience
*
* This file initializes the onboarding system and coordinates between
* the various components (TourManager, ProgressTracker, ThemeAdapter, etc.)
*/
(function() {
'use strict';
let progressTracker;
let themeAdapter;
let tourManager;
let dnsTemplateSelector;
let errorHandler;
/**
* Initialize the onboarding system
*/
async function initializeOnboarding() {
try {
console.log('[Onboarding] Initializing system...');
// Initialize Error Handler first
errorHandler = new ErrorHandler();
console.log('[Onboarding] Error Handler initialized');
// Initialize Progress Tracker
progressTracker = new ProgressTracker('dashcaddy_onboarding');
console.log('[Onboarding] Progress Tracker initialized');
// Initialize Theme Adapter
themeAdapter = new ThemeAdapter();
console.log('[Onboarding] Theme Adapter initialized');
// Initialize DNS Template Selector
dnsTemplateSelector = new DnsTemplateSelector(progressTracker);
console.log('[Onboarding] DNS Template Selector initialized');
// Initialize Tour Manager
tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector);
console.log('[Onboarding] Tour Manager initialized');
// Check if tour should auto-start
if (tourManager.shouldAutoStart()) {
console.log('[Onboarding] Auto-starting tour for first-time user');
// Wait a bit for page to fully load
setTimeout(() => {
tourManager.startTour();
}, 1000);
} else {
const tourCompleted = progressTracker.isTourCompleted();
const currentStep = progressTracker.getCurrentStep();
console.log(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`);
// If tour is in progress, offer to resume
if (!tourCompleted && currentStep > 0) {
console.log('[Onboarding] Tour in progress, can be resumed manually');
}
}
// Add restart tour button to tools row
addRestartTourButton();
// Expose to global scope for manual triggering
window.DashCaddyOnboarding = {
startTour: () => tourManager.startTour(),
restartTour: () => tourManager.restartTour(),
showTooltip: (id) => tourManager.showTooltip(id),
showWhatsNew: () => tourManager.showWhatsNew(),
resetProgress: () => progressTracker.resetProgress(),
getErrors: () => errorHandler.getErrors(),
getErrorStats: () => errorHandler.getStatistics()
};
console.log('[Onboarding] System initialized successfully');
} catch (error) {
console.error('[Onboarding] Initialization error:', error);
// Use error handler if available
if (errorHandler) {
errorHandler.logError('Initialization', error);
}
// Graceful degradation - don't break the dashboard
console.warn('[Onboarding] System failed to initialize, dashboard will continue without onboarding');
}
}
/**
* Add restart tour button to tools row
*/
function addRestartTourButton() {
const toolsRow = document.querySelector('.tools');
if (!toolsRow) return;
const clickHandler = () => {
if (tourManager) {
console.log('[Onboarding] Starting tour via button click');
tourManager.restartTour();
} else {
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');
}
};
// If button already exists in the HTML, just attach the handler
const existing = document.getElementById('restart-tour-btn');
if (existing) {
existing.onclick = clickHandler;
return;
}
const button = document.createElement('button');
button.id = 'restart-tour-btn';
button.textContent = 'Help Tour';
button.title = 'Restart the onboarding tour';
button.onclick = clickHandler;
toolsRow.appendChild(button);
}
/**
* Check if Driver.js is loaded
*/
function checkDriverLoaded() {
// Driver.js v1.x IIFE: window.driver.js.driver is the factory function
const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver;
if (typeof driverFactory !== 'function') {
console.warn('[Onboarding] Driver.js not loaded yet, will retry... window.driver:', window.driver);
return false;
}
return true;
}
/**
* Wait for Driver.js to load, then initialize
*/
function waitForDriver() {
let retries = 0;
const maxRetries = 10;
function attemptInit() {
if (checkDriverLoaded()) {
initializeOnboarding();
} else {
retries++;
if (retries < maxRetries) {
// Retry after a short delay
setTimeout(attemptInit, 500);
} else {
// Max retries reached, show fallback
console.error('[Onboarding] Driver.js failed to load after multiple attempts');
if (errorHandler) {
errorHandler.handleDriverLoadFailure();
} else {
// Create temporary error handler for fallback
const tempHandler = new ErrorHandler();
tempHandler.handleDriverLoadFailure();
}
}
}
}
attemptInit();
}
// Start initialization when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', waitForDriver);
} else {
waitForDriver();
}
console.log('[Onboarding] System loaded');
})();
/**
* DashCaddy User Onboarding System
* Main entry point for the tooltip-based onboarding experience
*
* This file initializes the onboarding system and coordinates between
* the various components (TourManager, ProgressTracker, ThemeAdapter, etc.)
*/
(function() {
'use strict';
let progressTracker;
let themeAdapter;
let tourManager;
let dnsTemplateSelector;
let errorHandler;
/**
* Initialize the onboarding system
*/
async function initializeOnboarding() {
try {
console.log('[Onboarding] Initializing system...');
// Initialize Error Handler first
errorHandler = new ErrorHandler();
console.log('[Onboarding] Error Handler initialized');
// Initialize Progress Tracker
progressTracker = new ProgressTracker('dashcaddy_onboarding');
console.log('[Onboarding] Progress Tracker initialized');
// Initialize Theme Adapter
themeAdapter = new ThemeAdapter();
console.log('[Onboarding] Theme Adapter initialized');
// Initialize DNS Template Selector
dnsTemplateSelector = new DnsTemplateSelector(progressTracker);
console.log('[Onboarding] DNS Template Selector initialized');
// Initialize Tour Manager
tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector);
console.log('[Onboarding] Tour Manager initialized');
// Check if tour should auto-start
if (tourManager.shouldAutoStart()) {
console.log('[Onboarding] Auto-starting tour for first-time user');
// Wait a bit for page to fully load
setTimeout(() => {
tourManager.startTour();
}, 1000);
} else {
const tourCompleted = progressTracker.isTourCompleted();
const currentStep = progressTracker.getCurrentStep();
console.log(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`);
// If tour is in progress, offer to resume
if (!tourCompleted && currentStep > 0) {
console.log('[Onboarding] Tour in progress, can be resumed manually');
}
}
// Add restart tour button to tools row
addRestartTourButton();
// Expose to global scope for manual triggering
window.DashCaddyOnboarding = {
startTour: () => tourManager.startTour(),
restartTour: () => tourManager.restartTour(),
showTooltip: (id) => tourManager.showTooltip(id),
showWhatsNew: () => tourManager.showWhatsNew(),
resetProgress: () => progressTracker.resetProgress(),
getErrors: () => errorHandler.getErrors(),
getErrorStats: () => errorHandler.getStatistics()
};
console.log('[Onboarding] System initialized successfully');
} catch (error) {
console.error('[Onboarding] Initialization error:', error);
// Use error handler if available
if (errorHandler) {
errorHandler.logError('Initialization', error);
}
// Graceful degradation - don't break the dashboard
console.warn('[Onboarding] System failed to initialize, dashboard will continue without onboarding');
}
}
/**
* Add restart tour button to tools row
*/
function addRestartTourButton() {
const toolsRow = document.querySelector('.tools');
if (!toolsRow) return;
const clickHandler = () => {
if (tourManager) {
console.log('[Onboarding] Starting tour via button click');
tourManager.restartTour();
} else {
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');
}
};
// If button already exists in the HTML, just attach the handler
const existing = document.getElementById('restart-tour-btn');
if (existing) {
existing.onclick = clickHandler;
return;
}
const button = document.createElement('button');
button.id = 'restart-tour-btn';
button.textContent = 'Help Tour';
button.title = 'Restart the onboarding tour';
button.onclick = clickHandler;
toolsRow.appendChild(button);
}
/**
* Check if Driver.js is loaded
*/
function checkDriverLoaded() {
// Driver.js v1.x IIFE: window.driver.js.driver is the factory function
const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver;
if (typeof driverFactory !== 'function') {
console.warn('[Onboarding] Driver.js not loaded yet, will retry... window.driver:', window.driver);
return false;
}
return true;
}
/**
* Wait for Driver.js to load, then initialize
*/
function waitForDriver() {
let retries = 0;
const maxRetries = 10;
function attemptInit() {
if (checkDriverLoaded()) {
initializeOnboarding();
} else {
retries++;
if (retries < maxRetries) {
// Retry after a short delay
setTimeout(attemptInit, 500);
} else {
// Max retries reached, show fallback
console.error('[Onboarding] Driver.js failed to load after multiple attempts');
if (errorHandler) {
errorHandler.handleDriverLoadFailure();
} else {
// Create temporary error handler for fallback
const tempHandler = new ErrorHandler();
tempHandler.handleDriverLoadFailure();
}
}
}
}
attemptInit();
}
// Start initialization when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', waitForDriver);
} else {
waitForDriver();
}
console.log('[Onboarding] System loaded');
})();
+282 -282
View File
@@ -1,282 +1,282 @@
/**
* Progress Tracker
* Manages persistent storage of user progress through the onboarding flow
* using browser local storage.
*
* Storage Schema:
* {
* "version": "1.0",
* "tourCompleted": false,
* "completedTooltips": ["welcome", "dns-priority", ...],
* "currentStep": 3,
* "completionTimestamp": "2024-01-15T10:30:00Z",
* "dnsSetupDeferred": false,
* "lastVisit": "2024-01-15T10:30:00Z"
* }
*/
(function(window) {
'use strict';
/**
* ProgressTracker class
* Manages persistent storage of onboarding progress
*
* @class
* @param {string} storageKey - The key to use for local storage (default: 'dashcaddy_onboarding')
*/
class ProgressTracker {
constructor(storageKey = 'dashcaddy_onboarding') {
this.storageKey = storageKey;
this.storageVersion = '1.0';
// Initialize storage if it doesn't exist
this._initializeStorage();
// Update last visit timestamp
this._updateLastVisit();
}
/**
* Initialize storage with default values if it doesn't exist
* @private
*/
_initializeStorage() {
const existing = this._getStorage();
if (!existing || existing.version !== this.storageVersion) {
const defaultState = {
version: this.storageVersion,
tourCompleted: false,
completedTooltips: [],
currentStep: 0,
completionTimestamp: null,
dnsSetupDeferred: false,
lastVisit: new Date().toISOString()
};
this._setStorage(defaultState);
}
}
/**
* Get the current storage state
* @private
* @returns {Object|null} The storage state or null if unavailable
*/
_getStorage() {
try {
const data = localStorage.getItem(this.storageKey);
return data ? JSON.parse(data) : null;
} catch (error) {
console.error('[ProgressTracker] Error reading from storage:', error);
return null;
}
}
/**
* Set the storage state
* @private
* @param {Object} state - The state to save
*/
_setStorage(state) {
try {
localStorage.setItem(this.storageKey, JSON.stringify(state));
} catch (error) {
console.error('[ProgressTracker] Error writing to storage:', error);
// Handle quota exceeded or storage unavailable
// Fall back to session storage or in-memory storage
this._handleStorageError(error);
}
}
/**
* Handle storage errors (quota exceeded, unavailable, etc.)
* @private
* @param {Error} error - The error that occurred
*/
_handleStorageError(error) {
// Try session storage as fallback
try {
sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage()));
console.warn('[ProgressTracker] Falling back to session storage');
} catch (sessionError) {
console.error('[ProgressTracker] Session storage also unavailable:', sessionError);
// Could implement in-memory fallback here if needed
}
}
/**
* Update the last visit timestamp
* @private
*/
_updateLastVisit() {
const state = this._getStorage();
if (state) {
state.lastVisit = new Date().toISOString();
this._setStorage(state);
}
}
/**
* Check if a specific tooltip has been completed
* @param {string} tooltipId - The ID of the tooltip to check
* @returns {boolean} True if the tooltip has been completed
*/
isTooltipCompleted(tooltipId) {
const state = this._getStorage();
if (!state) return false;
return state.completedTooltips.includes(tooltipId);
}
/**
* Mark a tooltip as completed with timestamp
* @param {string} tooltipId - The ID of the tooltip to mark as completed
*/
markTooltipCompleted(tooltipId) {
const state = this._getStorage();
if (!state) return;
// Add tooltip to completed list if not already there
if (!state.completedTooltips.includes(tooltipId)) {
state.completedTooltips.push(tooltipId);
// Store timestamp for this specific tooltip
if (!state.tooltipTimestamps) {
state.tooltipTimestamps = {};
}
state.tooltipTimestamps[tooltipId] = new Date().toISOString();
this._setStorage(state);
}
}
/**
* Check if the entire tour has been completed
* @returns {boolean} True if the tour is completed
*/
isTourCompleted() {
const state = this._getStorage();
if (!state) return false;
return state.tourCompleted === true;
}
/**
* Mark the entire tour as completed
*/
markTourCompleted() {
const state = this._getStorage();
if (!state) return;
state.tourCompleted = true;
state.completionTimestamp = new Date().toISOString();
this._setStorage(state);
}
/**
* Get the current step index
* @returns {number} The current step index (0-based)
*/
getCurrentStep() {
const state = this._getStorage();
if (!state) return 0;
return state.currentStep || 0;
}
/**
* Set the current step index
* @param {number} stepIndex - The step index to set (0-based)
*/
setCurrentStep(stepIndex) {
const state = this._getStorage();
if (!state) return;
state.currentStep = stepIndex;
this._setStorage(state);
}
/**
* Reset all progress and clear storage
*/
resetProgress() {
const defaultState = {
version: this.storageVersion,
tourCompleted: false,
completedTooltips: [],
currentStep: 0,
completionTimestamp: null,
dnsSetupDeferred: false,
lastVisit: new Date().toISOString()
};
this._setStorage(defaultState);
}
/**
* Get the completion timestamp
* @returns {Date|null} The completion timestamp or null if not completed
*/
getCompletionTimestamp() {
const state = this._getStorage();
if (!state || !state.completionTimestamp) return null;
return new Date(state.completionTimestamp);
}
/**
* Check if DNS setup was deferred
* @returns {boolean} True if DNS setup was deferred
*/
isDnsSetupDeferred() {
const state = this._getStorage();
if (!state) return false;
return state.dnsSetupDeferred === true;
}
/**
* Mark DNS setup as deferred
*/
markDnsSetupDeferred() {
const state = this._getStorage();
if (!state) return;
state.dnsSetupDeferred = true;
this._setStorage(state);
}
/**
* Get the timestamp for a specific tooltip completion
* @param {string} tooltipId - The ID of the tooltip
* @returns {Date|null} The timestamp or null if not completed
*/
getTooltipTimestamp(tooltipId) {
const state = this._getStorage();
if (!state || !state.tooltipTimestamps || !state.tooltipTimestamps[tooltipId]) {
return null;
}
return new Date(state.tooltipTimestamps[tooltipId]);
}
/**
* Get all completed tooltip IDs
* @returns {string[]} Array of completed tooltip IDs
*/
getCompletedTooltips() {
const state = this._getStorage();
if (!state) return [];
return state.completedTooltips || [];
}
/**
* Get the last visit timestamp
* @returns {Date|null} The last visit timestamp
*/
getLastVisit() {
const state = this._getStorage();
if (!state || !state.lastVisit) return null;
return new Date(state.lastVisit);
}
}
// Export to global scope
window.ProgressTracker = ProgressTracker;
console.log('[ProgressTracker] Module loaded');
})(window);
/**
* Progress Tracker
* Manages persistent storage of user progress through the onboarding flow
* using browser local storage.
*
* Storage Schema:
* {
* "version": "1.0",
* "tourCompleted": false,
* "completedTooltips": ["welcome", "dns-priority", ...],
* "currentStep": 3,
* "completionTimestamp": "2024-01-15T10:30:00Z",
* "dnsSetupDeferred": false,
* "lastVisit": "2024-01-15T10:30:00Z"
* }
*/
(function(window) {
'use strict';
/**
* ProgressTracker class
* Manages persistent storage of onboarding progress
*
* @class
* @param {string} storageKey - The key to use for local storage (default: 'dashcaddy_onboarding')
*/
class ProgressTracker {
constructor(storageKey = 'dashcaddy_onboarding') {
this.storageKey = storageKey;
this.storageVersion = '1.0';
// Initialize storage if it doesn't exist
this._initializeStorage();
// Update last visit timestamp
this._updateLastVisit();
}
/**
* Initialize storage with default values if it doesn't exist
* @private
*/
_initializeStorage() {
const existing = this._getStorage();
if (!existing || existing.version !== this.storageVersion) {
const defaultState = {
version: this.storageVersion,
tourCompleted: false,
completedTooltips: [],
currentStep: 0,
completionTimestamp: null,
dnsSetupDeferred: false,
lastVisit: new Date().toISOString()
};
this._setStorage(defaultState);
}
}
/**
* Get the current storage state
* @private
* @returns {Object|null} The storage state or null if unavailable
*/
_getStorage() {
try {
const data = localStorage.getItem(this.storageKey);
return data ? JSON.parse(data) : null;
} catch (error) {
console.error('[ProgressTracker] Error reading from storage:', error);
return null;
}
}
/**
* Set the storage state
* @private
* @param {Object} state - The state to save
*/
_setStorage(state) {
try {
localStorage.setItem(this.storageKey, JSON.stringify(state));
} catch (error) {
console.error('[ProgressTracker] Error writing to storage:', error);
// Handle quota exceeded or storage unavailable
// Fall back to session storage or in-memory storage
this._handleStorageError(error);
}
}
/**
* Handle storage errors (quota exceeded, unavailable, etc.)
* @private
* @param {Error} error - The error that occurred
*/
_handleStorageError(error) {
// Try session storage as fallback
try {
sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage()));
console.warn('[ProgressTracker] Falling back to session storage');
} catch (sessionError) {
console.error('[ProgressTracker] Session storage also unavailable:', sessionError);
// Could implement in-memory fallback here if needed
}
}
/**
* Update the last visit timestamp
* @private
*/
_updateLastVisit() {
const state = this._getStorage();
if (state) {
state.lastVisit = new Date().toISOString();
this._setStorage(state);
}
}
/**
* Check if a specific tooltip has been completed
* @param {string} tooltipId - The ID of the tooltip to check
* @returns {boolean} True if the tooltip has been completed
*/
isTooltipCompleted(tooltipId) {
const state = this._getStorage();
if (!state) return false;
return state.completedTooltips.includes(tooltipId);
}
/**
* Mark a tooltip as completed with timestamp
* @param {string} tooltipId - The ID of the tooltip to mark as completed
*/
markTooltipCompleted(tooltipId) {
const state = this._getStorage();
if (!state) return;
// Add tooltip to completed list if not already there
if (!state.completedTooltips.includes(tooltipId)) {
state.completedTooltips.push(tooltipId);
// Store timestamp for this specific tooltip
if (!state.tooltipTimestamps) {
state.tooltipTimestamps = {};
}
state.tooltipTimestamps[tooltipId] = new Date().toISOString();
this._setStorage(state);
}
}
/**
* Check if the entire tour has been completed
* @returns {boolean} True if the tour is completed
*/
isTourCompleted() {
const state = this._getStorage();
if (!state) return false;
return state.tourCompleted === true;
}
/**
* Mark the entire tour as completed
*/
markTourCompleted() {
const state = this._getStorage();
if (!state) return;
state.tourCompleted = true;
state.completionTimestamp = new Date().toISOString();
this._setStorage(state);
}
/**
* Get the current step index
* @returns {number} The current step index (0-based)
*/
getCurrentStep() {
const state = this._getStorage();
if (!state) return 0;
return state.currentStep || 0;
}
/**
* Set the current step index
* @param {number} stepIndex - The step index to set (0-based)
*/
setCurrentStep(stepIndex) {
const state = this._getStorage();
if (!state) return;
state.currentStep = stepIndex;
this._setStorage(state);
}
/**
* Reset all progress and clear storage
*/
resetProgress() {
const defaultState = {
version: this.storageVersion,
tourCompleted: false,
completedTooltips: [],
currentStep: 0,
completionTimestamp: null,
dnsSetupDeferred: false,
lastVisit: new Date().toISOString()
};
this._setStorage(defaultState);
}
/**
* Get the completion timestamp
* @returns {Date|null} The completion timestamp or null if not completed
*/
getCompletionTimestamp() {
const state = this._getStorage();
if (!state || !state.completionTimestamp) return null;
return new Date(state.completionTimestamp);
}
/**
* Check if DNS setup was deferred
* @returns {boolean} True if DNS setup was deferred
*/
isDnsSetupDeferred() {
const state = this._getStorage();
if (!state) return false;
return state.dnsSetupDeferred === true;
}
/**
* Mark DNS setup as deferred
*/
markDnsSetupDeferred() {
const state = this._getStorage();
if (!state) return;
state.dnsSetupDeferred = true;
this._setStorage(state);
}
/**
* Get the timestamp for a specific tooltip completion
* @param {string} tooltipId - The ID of the tooltip
* @returns {Date|null} The timestamp or null if not completed
*/
getTooltipTimestamp(tooltipId) {
const state = this._getStorage();
if (!state || !state.tooltipTimestamps || !state.tooltipTimestamps[tooltipId]) {
return null;
}
return new Date(state.tooltipTimestamps[tooltipId]);
}
/**
* Get all completed tooltip IDs
* @returns {string[]} Array of completed tooltip IDs
*/
getCompletedTooltips() {
const state = this._getStorage();
if (!state) return [];
return state.completedTooltips || [];
}
/**
* Get the last visit timestamp
* @returns {Date|null} The last visit timestamp
*/
getLastVisit() {
const state = this._getStorage();
if (!state || !state.lastVisit) return null;
return new Date(state.lastVisit);
}
}
// Export to global scope
window.ProgressTracker = ProgressTracker;
console.log('[ProgressTracker] Module loaded');
})(window);
+337 -337
View File
@@ -1,337 +1,337 @@
/**
* Tooltip Definitions
* Defines all tooltip content, positioning, and behavior for the onboarding system
*/
(function(window) {
'use strict';
/**
* Validate a tooltip definition
* @param {Object} tooltip - The tooltip definition to validate
* @returns {Object} { valid: boolean, errors: string[] }
*/
function validateTooltipDefinition(tooltip) {
const errors = [];
// Required fields
if (!tooltip.id || typeof tooltip.id !== 'string') {
errors.push('Tooltip must have a valid string id');
}
if (!tooltip.element) {
errors.push('Tooltip must have an element selector or HTMLElement');
}
if (!tooltip.popover || typeof tooltip.popover !== 'object') {
errors.push('Tooltip must have a popover object');
} else {
// Validate popover fields
if (!tooltip.popover.title || typeof tooltip.popover.title !== 'string') {
errors.push('Tooltip popover must have a valid string title');
}
if (!tooltip.popover.description || typeof tooltip.popover.description !== 'string') {
errors.push('Tooltip popover must have a valid string description');
}
// Validate position if provided
if (tooltip.popover.position) {
const validPositions = ['top', 'bottom', 'left', 'right', 'center'];
if (!validPositions.includes(tooltip.popover.position)) {
errors.push(`Invalid position: ${tooltip.popover.position}. Must be one of: ${validPositions.join(', ')}`);
}
}
// Validate align if provided
if (tooltip.popover.align) {
const validAligns = ['start', 'center', 'end'];
if (!validAligns.includes(tooltip.popover.align)) {
errors.push(`Invalid align: ${tooltip.popover.align}. Must be one of: ${validAligns.join(', ')}`);
}
}
// Validate showButtons if provided
if (tooltip.popover.showButtons && !Array.isArray(tooltip.popover.showButtons)) {
errors.push('showButtons must be an array');
}
// Validate callbacks if provided
const callbacks = ['onNext', 'onPrevious', 'onClose', 'onSetupNow', 'onLater'];
callbacks.forEach(callback => {
if (tooltip.popover[callback] && typeof tooltip.popover[callback] !== 'function') {
errors.push(`${callback} must be a function`);
}
});
}
// Validate condition if provided
if (tooltip.condition && typeof tooltip.condition !== 'function') {
errors.push('condition must be a function');
}
// Validate priority if provided
if (tooltip.priority !== undefined && typeof tooltip.priority !== 'number') {
errors.push('priority must be a number');
}
return {
valid: errors.length === 0,
errors
};
}
/**
* Validate an array of tooltip definitions
* @param {Array} tooltips - Array of tooltip definitions
* @returns {Object} { valid: boolean, errors: Object[] }
*/
function validateTooltipDefinitions(tooltips) {
if (!Array.isArray(tooltips)) {
return {
valid: false,
errors: [{ tooltip: null, errors: ['tooltips must be an array'] }]
};
}
const allErrors = [];
const ids = new Set();
tooltips.forEach((tooltip, index) => {
const validation = validateTooltipDefinition(tooltip);
if (!validation.valid) {
allErrors.push({
tooltip: tooltip.id || `index ${index}`,
errors: validation.errors
});
}
// Check for duplicate IDs
if (tooltip.id) {
if (ids.has(tooltip.id)) {
allErrors.push({
tooltip: tooltip.id,
errors: [`Duplicate tooltip ID: ${tooltip.id}`]
});
}
ids.add(tooltip.id);
}
});
return {
valid: allErrors.length === 0,
errors: allErrors
};
}
/**
* Error handler for tooltip system
*/
class TooltipError extends Error {
constructor(message, tooltipId = null) {
super(message);
this.name = 'TooltipError';
this.tooltipId = tooltipId;
}
}
/**
* Handle tooltip definition errors
* @param {Object} validation - Validation result
* @throws {TooltipError} If validation fails
*/
function handleValidationErrors(validation) {
if (!validation.valid) {
const errorMessages = validation.errors.map(e =>
`${e.tooltip}: ${e.errors.join(', ')}`
).join('\n');
console.error('[TooltipDefinitions] Validation errors:', errorMessages);
throw new TooltipError(`Tooltip validation failed:\n${errorMessages}`);
}
}
// Export to global scope
window.TooltipValidation = {
validateTooltipDefinition,
validateTooltipDefinitions,
handleValidationErrors,
TooltipError
};
console.log('[TooltipDefinitions] Validation module loaded');
})(window);
/**
* Tooltip Definitions Array
* Defines all tooltips for the onboarding tour
*/
const TOOLTIP_DEFINITIONS = [
// 1. Welcome tooltip pointing to logo
{
id: 'welcome',
element: '#brand',
popover: {
title: 'Welcome to DashCaddy!',
description: `
<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 style="margin-top: 8px; font-size: 0.85rem; opacity: 0.8;">Tip: You can customize this logo in Settings.</p>
`,
position: 'bottom',
align: 'start',
showButtons: ['next'],
showProgress: true
},
priority: 1,
isNewFeature: false
},
// 2. Add Service button
{
id: 'add-service',
element: '#add-service-btn',
popover: {
title: 'Adding New Services',
description: `
<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>
<ul>
<li>Media servers (Plex, Jellyfin, Emby)</li>
<li>Download managers (qBittorrent, Transmission)</li>
<li>DNS servers (Technitium, Pi-hole)</li>
</ul>
`,
position: 'bottom',
showButtons: ['previous', 'next'],
showProgress: true
},
priority: 2,
isNewFeature: false,
condition: () => {
return document.getElementById('add-service-btn') !== null;
}
},
// 3. App Grid explanation
{
id: 'app-grid',
element: '#cards',
popover: {
title: 'Your Services',
description: `
<p>This is your service grid where all your deployed applications appear.</p>
<p>Each card shows:</p>
<ul>
<li>Service status (online/offline)</li>
<li>Response time</li>
<li>Quick actions (restart, open, logs, settings)</li>
</ul>
`,
position: 'top',
showButtons: ['previous', 'next'],
showProgress: true
},
priority: 3,
isNewFeature: false
},
// 4. Theme selector
{
id: 'theme-selector',
element: '#theme',
popover: {
title: 'Customize Your Theme',
description: `
<p>DashCaddy comes with 7 themes. Click here to switch between them.</p>
<p>Your preference is saved automatically.</p>
`,
position: 'bottom',
showButtons: ['previous', 'close'],
showProgress: true
},
priority: 4,
isNewFeature: false,
condition: () => {
return document.getElementById('theme') !== null;
}
}
];
/**
* Get tooltip definitions
* @returns {Array} Array of tooltip definitions
*/
function getTooltipDefinitions() {
return TOOLTIP_DEFINITIONS;
}
/**
* Get a specific tooltip by ID
* @param {string} id - Tooltip ID
* @returns {Object|null} Tooltip definition or null if not found
*/
function getTooltipById(id) {
return TOOLTIP_DEFINITIONS.find(t => t.id === id) || null;
}
/**
* Get tooltips filtered by condition
* @returns {Array} Array of tooltips that pass their condition check
*/
function getActiveTooltips() {
return TOOLTIP_DEFINITIONS.filter(tooltip => {
if (tooltip.condition && typeof tooltip.condition === 'function') {
try {
return tooltip.condition();
} catch (error) {
console.error(`[TooltipDefinitions] Error evaluating condition for ${tooltip.id}:`, error);
return false;
}
}
return true;
});
}
/**
* Get tooltips sorted by priority
* @returns {Array} Array of tooltips sorted by priority (ascending)
*/
function getSortedTooltips() {
const tooltips = getActiveTooltips();
return tooltips.sort((a, b) => {
const priorityA = a.priority || 999;
const priorityB = b.priority || 999;
return priorityA - priorityB;
});
}
/**
* Get tooltips marked as new features
* @returns {Array} Array of tooltips marked with isNewFeature flag
*/
function getNewFeatureTooltips() {
const tooltips = getActiveTooltips();
return tooltips.filter(tooltip => tooltip.isNewFeature === true)
.sort((a, b) => {
const priorityA = a.priority || 999;
const priorityB = b.priority || 999;
return priorityA - priorityB;
});
}
// Export to global scope
window.TooltipDefinitions = {
TOOLTIP_DEFINITIONS,
getTooltipDefinitions,
getTooltipById,
getActiveTooltips,
getSortedTooltips,
getNewFeatureTooltips
};
console.log('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips');
/**
* Tooltip Definitions
* Defines all tooltip content, positioning, and behavior for the onboarding system
*/
(function(window) {
'use strict';
/**
* Validate a tooltip definition
* @param {Object} tooltip - The tooltip definition to validate
* @returns {Object} { valid: boolean, errors: string[] }
*/
function validateTooltipDefinition(tooltip) {
const errors = [];
// Required fields
if (!tooltip.id || typeof tooltip.id !== 'string') {
errors.push('Tooltip must have a valid string id');
}
if (!tooltip.element) {
errors.push('Tooltip must have an element selector or HTMLElement');
}
if (!tooltip.popover || typeof tooltip.popover !== 'object') {
errors.push('Tooltip must have a popover object');
} else {
// Validate popover fields
if (!tooltip.popover.title || typeof tooltip.popover.title !== 'string') {
errors.push('Tooltip popover must have a valid string title');
}
if (!tooltip.popover.description || typeof tooltip.popover.description !== 'string') {
errors.push('Tooltip popover must have a valid string description');
}
// Validate position if provided
if (tooltip.popover.position) {
const validPositions = ['top', 'bottom', 'left', 'right', 'center'];
if (!validPositions.includes(tooltip.popover.position)) {
errors.push(`Invalid position: ${tooltip.popover.position}. Must be one of: ${validPositions.join(', ')}`);
}
}
// Validate align if provided
if (tooltip.popover.align) {
const validAligns = ['start', 'center', 'end'];
if (!validAligns.includes(tooltip.popover.align)) {
errors.push(`Invalid align: ${tooltip.popover.align}. Must be one of: ${validAligns.join(', ')}`);
}
}
// Validate showButtons if provided
if (tooltip.popover.showButtons && !Array.isArray(tooltip.popover.showButtons)) {
errors.push('showButtons must be an array');
}
// Validate callbacks if provided
const callbacks = ['onNext', 'onPrevious', 'onClose', 'onSetupNow', 'onLater'];
callbacks.forEach(callback => {
if (tooltip.popover[callback] && typeof tooltip.popover[callback] !== 'function') {
errors.push(`${callback} must be a function`);
}
});
}
// Validate condition if provided
if (tooltip.condition && typeof tooltip.condition !== 'function') {
errors.push('condition must be a function');
}
// Validate priority if provided
if (tooltip.priority !== undefined && typeof tooltip.priority !== 'number') {
errors.push('priority must be a number');
}
return {
valid: errors.length === 0,
errors
};
}
/**
* Validate an array of tooltip definitions
* @param {Array} tooltips - Array of tooltip definitions
* @returns {Object} { valid: boolean, errors: Object[] }
*/
function validateTooltipDefinitions(tooltips) {
if (!Array.isArray(tooltips)) {
return {
valid: false,
errors: [{ tooltip: null, errors: ['tooltips must be an array'] }]
};
}
const allErrors = [];
const ids = new Set();
tooltips.forEach((tooltip, index) => {
const validation = validateTooltipDefinition(tooltip);
if (!validation.valid) {
allErrors.push({
tooltip: tooltip.id || `index ${index}`,
errors: validation.errors
});
}
// Check for duplicate IDs
if (tooltip.id) {
if (ids.has(tooltip.id)) {
allErrors.push({
tooltip: tooltip.id,
errors: [`Duplicate tooltip ID: ${tooltip.id}`]
});
}
ids.add(tooltip.id);
}
});
return {
valid: allErrors.length === 0,
errors: allErrors
};
}
/**
* Error handler for tooltip system
*/
class TooltipError extends Error {
constructor(message, tooltipId = null) {
super(message);
this.name = 'TooltipError';
this.tooltipId = tooltipId;
}
}
/**
* Handle tooltip definition errors
* @param {Object} validation - Validation result
* @throws {TooltipError} If validation fails
*/
function handleValidationErrors(validation) {
if (!validation.valid) {
const errorMessages = validation.errors.map(e =>
`${e.tooltip}: ${e.errors.join(', ')}`
).join('\n');
console.error('[TooltipDefinitions] Validation errors:', errorMessages);
throw new TooltipError(`Tooltip validation failed:\n${errorMessages}`);
}
}
// Export to global scope
window.TooltipValidation = {
validateTooltipDefinition,
validateTooltipDefinitions,
handleValidationErrors,
TooltipError
};
console.log('[TooltipDefinitions] Validation module loaded');
})(window);
/**
* Tooltip Definitions Array
* Defines all tooltips for the onboarding tour
*/
const TOOLTIP_DEFINITIONS = [
// 1. Welcome tooltip pointing to logo
{
id: 'welcome',
element: '#brand',
popover: {
title: 'Welcome to DashCaddy!',
description: `
<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 style="margin-top: 8px; font-size: 0.85rem; opacity: 0.8;">Tip: You can customize this logo in Settings.</p>
`,
position: 'bottom',
align: 'start',
showButtons: ['next'],
showProgress: true
},
priority: 1,
isNewFeature: false
},
// 2. Add Service button
{
id: 'add-service',
element: '#add-service-btn',
popover: {
title: 'Adding New Services',
description: `
<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>
<ul>
<li>Media servers (Plex, Jellyfin, Emby)</li>
<li>Download managers (qBittorrent, Transmission)</li>
<li>DNS servers (Technitium, Pi-hole)</li>
</ul>
`,
position: 'bottom',
showButtons: ['previous', 'next'],
showProgress: true
},
priority: 2,
isNewFeature: false,
condition: () => {
return document.getElementById('add-service-btn') !== null;
}
},
// 3. App Grid explanation
{
id: 'app-grid',
element: '#cards',
popover: {
title: 'Your Services',
description: `
<p>This is your service grid where all your deployed applications appear.</p>
<p>Each card shows:</p>
<ul>
<li>Service status (online/offline)</li>
<li>Response time</li>
<li>Quick actions (restart, open, logs, settings)</li>
</ul>
`,
position: 'top',
showButtons: ['previous', 'next'],
showProgress: true
},
priority: 3,
isNewFeature: false
},
// 4. Theme selector
{
id: 'theme-selector',
element: '#theme',
popover: {
title: 'Customize Your Theme',
description: `
<p>DashCaddy comes with 7 themes. Click here to switch between them.</p>
<p>Your preference is saved automatically.</p>
`,
position: 'bottom',
showButtons: ['previous', 'close'],
showProgress: true
},
priority: 4,
isNewFeature: false,
condition: () => {
return document.getElementById('theme') !== null;
}
}
];
/**
* Get tooltip definitions
* @returns {Array} Array of tooltip definitions
*/
function getTooltipDefinitions() {
return TOOLTIP_DEFINITIONS;
}
/**
* Get a specific tooltip by ID
* @param {string} id - Tooltip ID
* @returns {Object|null} Tooltip definition or null if not found
*/
function getTooltipById(id) {
return TOOLTIP_DEFINITIONS.find(t => t.id === id) || null;
}
/**
* Get tooltips filtered by condition
* @returns {Array} Array of tooltips that pass their condition check
*/
function getActiveTooltips() {
return TOOLTIP_DEFINITIONS.filter(tooltip => {
if (tooltip.condition && typeof tooltip.condition === 'function') {
try {
return tooltip.condition();
} catch (error) {
console.error(`[TooltipDefinitions] Error evaluating condition for ${tooltip.id}:`, error);
return false;
}
}
return true;
});
}
/**
* Get tooltips sorted by priority
* @returns {Array} Array of tooltips sorted by priority (ascending)
*/
function getSortedTooltips() {
const tooltips = getActiveTooltips();
return tooltips.sort((a, b) => {
const priorityA = a.priority || 999;
const priorityB = b.priority || 999;
return priorityA - priorityB;
});
}
/**
* Get tooltips marked as new features
* @returns {Array} Array of tooltips marked with isNewFeature flag
*/
function getNewFeatureTooltips() {
const tooltips = getActiveTooltips();
return tooltips.filter(tooltip => tooltip.isNewFeature === true)
.sort((a, b) => {
const priorityA = a.priority || 999;
const priorityB = b.priority || 999;
return priorityA - priorityB;
});
}
// Export to global scope
window.TooltipDefinitions = {
TOOLTIP_DEFINITIONS,
getTooltipDefinitions,
getTooltipById,
getActiveTooltips,
getSortedTooltips,
getNewFeatureTooltips
};
console.log('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips');
+363 -363
View File
@@ -1,363 +1,363 @@
/**
* Tour Manager
* Orchestrates the onboarding tour using Driver.js
*/
(function(window) {
'use strict';
class TourManager {
constructor(progressTracker, themeAdapter, dnsTemplateSelector) {
this.progressTracker = progressTracker;
this.themeAdapter = themeAdapter;
this.dnsTemplateSelector = dnsTemplateSelector;
this.driver = null;
this.currentStepIndex = 0;
this.isActive = false;
this.resizeHandler = null;
this.layoutChangeHandler = null;
}
/**
* Initialize Driver.js with theme-aware configuration
*/
async initializeDriver() {
// Driver.js v1.x IIFE: window.driver.js.driver is the factory function
const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver;
if (typeof driverFactory !== 'function') {
console.error('[TourManager] Driver.js not loaded or invalid. window.driver:', window.driver);
return false;
}
const themeConfig = this.themeAdapter.getDriverTheme();
this.driver = driverFactory({
showProgress: true,
showButtons: ['next', 'previous', 'close'],
allowClose: true,
overlayClickNext: false,
overlayOpacity: 0,
stagePadding: 0,
stageRadius: 0,
allowKeyboardControl: true,
popoverClass: 'dashcaddy-popover',
onDestroyed: () => this.onTourComplete(),
onDestroyStarted: () => {
if (!this.progressTracker.isTourCompleted()) {
this.onTourSkip();
}
}
});
// Apply theme
this.themeAdapter.applyTheme(this.driver);
// Listen for theme changes
this.themeAdapter.onThemeChange(() => {
this.themeAdapter.applyTheme(this.driver);
});
// Set up dynamic repositioning
this.setupDynamicRepositioning();
return true;
}
/**
* Check if tour should auto-start
*/
shouldAutoStart() {
return !this.progressTracker.isTourCompleted() &&
this.progressTracker.getCurrentStep() === 0;
}
/**
* Start the onboarding tour
*/
async startTour() {
if (!this.driver) {
const initialized = await this.initializeDriver();
if (!initialized) return;
}
// Get active tooltips (filtered by conditions)
const allTooltips = window.TooltipDefinitions.getSortedTooltips();
// Filter out completed tooltips
const completedIds = this.progressTracker.getCompletedTooltips();
const activeTooltips = allTooltips.filter(t => !completedIds.includes(t.id));
if (activeTooltips.length === 0) {
console.log('[TourManager] No tooltips to show');
this.progressTracker.markTourCompleted();
return;
}
// Convert to Driver.js steps with navigation logic
const steps = activeTooltips.map((tooltip, index) => {
const isFirst = index === 0;
const isLast = index === activeTooltips.length - 1;
const step = {
element: tooltip.element,
popover: {
title: tooltip.popover.title,
description: tooltip.popover.description,
side: tooltip.popover.position || 'bottom',
align: tooltip.popover.align || 'start',
showButtons: this._getButtonsForStep(tooltip, isFirst, isLast),
showProgress: tooltip.popover.showProgress !== false,
onNextClick: () => {
this.progressTracker.markTooltipCompleted(tooltip.id);
this.progressTracker.setCurrentStep(index + 1);
this.currentStepIndex = index + 1;
this.driver.moveNext();
},
onPrevClick: () => {
this.progressTracker.setCurrentStep(Math.max(0, index - 1));
this.currentStepIndex = Math.max(0, index - 1);
this.driver.movePrevious();
},
onCloseClick: () => {
this.skipTour();
}
}
};
// Add custom handlers for DNS tooltip
if (tooltip.id === 'dns-priority' && this.dnsTemplateSelector) {
step.popover.onSetupNowClick = () => {
console.log('[TourManager] Opening DNS template selector');
this.dnsTemplateSelector.showTemplateSelector();
// Mark tooltip as completed and move to next
this.progressTracker.markTooltipCompleted(tooltip.id);
this.progressTracker.setCurrentStep(index + 1);
this.currentStepIndex = index + 1;
this.driver.moveNext();
};
step.popover.onLaterClick = () => {
console.log('[TourManager] DNS setup deferred');
this.progressTracker.markDnsSetupDeferred();
// Mark tooltip as completed and move to next
this.progressTracker.markTooltipCompleted(tooltip.id);
this.progressTracker.setCurrentStep(index + 1);
this.currentStepIndex = index + 1;
this.driver.moveNext();
};
}
return step;
});
this.isActive = true;
this.driver.setSteps(steps);
this.driver.drive();
}
/**
* Resume tour from last step
*/
async resumeTour() {
const currentStep = this.progressTracker.getCurrentStep();
if (currentStep > 0) {
await this.startTour();
// Driver.js will start from beginning, we'd need to skip to current step
// This is a simplified implementation
} else {
await this.startTour();
}
}
/**
* Skip the entire tour
*/
skipTour() {
if (this.driver) {
this.driver.destroy();
}
this.cleanupDynamicRepositioning();
this.isActive = false;
}
/**
* Restart tour from beginning
*/
async restartTour() {
this.progressTracker.resetProgress();
await this.startTour();
}
/**
* Show a specific tooltip by ID
*/
async showTooltip(tooltipId) {
const tooltip = window.TooltipDefinitions.getTooltipById(tooltipId);
if (!tooltip) {
console.error(`[TourManager] Tooltip not found: ${tooltipId}`);
return;
}
if (!this.driver) {
await this.initializeDriver();
}
const step = {
element: tooltip.element,
popover: {
title: tooltip.popover.title,
description: tooltip.popover.description,
side: tooltip.popover.position || 'bottom',
align: tooltip.popover.align || 'start'
}
};
this.driver.highlight(step);
}
/**
* Show "What's New" tour - only tooltips marked as new features
*/
async showWhatsNew() {
if (!this.driver) {
const initialized = await this.initializeDriver();
if (!initialized) return;
}
// Get only new feature tooltips
const newFeatureTooltips = window.TooltipDefinitions.getNewFeatureTooltips();
if (newFeatureTooltips.length === 0) {
console.log('[TourManager] No new features to show');
return;
}
console.log(`[TourManager] Showing ${newFeatureTooltips.length} new features`);
// Convert to Driver.js steps
const steps = newFeatureTooltips.map((tooltip, index) => {
const isFirst = index === 0;
const isLast = index === newFeatureTooltips.length - 1;
return {
element: tooltip.element,
popover: {
title: `✨ NEW: ${tooltip.popover.title}`,
description: tooltip.popover.description,
side: tooltip.popover.position || 'bottom',
align: tooltip.popover.align || 'start',
showButtons: this._getButtonsForStep(tooltip, isFirst, isLast),
showProgress: true,
onNextClick: () => {
this.driver.moveNext();
},
onPrevClick: () => {
this.driver.movePrevious();
},
onCloseClick: () => {
this.skipTour();
}
}
};
});
this.isActive = true;
this.driver.setSteps(steps);
this.driver.drive();
}
/**
* Set up dynamic repositioning for window resize and layout changes
*/
setupDynamicRepositioning() {
// Window resize handler with debouncing
let resizeTimeout;
this.resizeHandler = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
if (this.isActive && this.driver) {
console.log('[TourManager] Window resized, repositioning tooltip');
this.driver.refresh();
}
}, 150); // Debounce for 150ms
};
// Layout change handler (for theme changes, DOM mutations)
this.layoutChangeHandler = () => {
if (this.isActive && this.driver) {
console.log('[TourManager] Layout changed, repositioning tooltip');
// Small delay to allow layout to settle
setTimeout(() => {
if (this.driver) {
this.driver.refresh();
}
}, 100);
}
};
// Add event listeners
window.addEventListener('resize', this.resizeHandler);
// Listen for theme changes (already handled by ThemeAdapter, but also trigger reposition)
this.themeAdapter.onThemeChange(this.layoutChangeHandler);
}
/**
* Clean up dynamic repositioning listeners
*/
cleanupDynamicRepositioning() {
if (this.resizeHandler) {
window.removeEventListener('resize', this.resizeHandler);
}
}
/**
* Get buttons to show for a specific step
* @private
*/
_getButtonsForStep(tooltip, isFirst, isLast) {
// Check if tooltip has custom buttons defined
if (tooltip.popover.showButtons) {
return tooltip.popover.showButtons;
}
// Default button configuration
const buttons = [];
if (!isFirst) {
buttons.push('previous');
}
if (!isLast) {
buttons.push('next');
} else {
buttons.push('close');
}
return buttons;
}
/**
* Handle tour completion
*/
onTourComplete() {
this.progressTracker.markTourCompleted();
this.isActive = false;
console.log('[TourManager] Tour completed');
}
/**
* Handle tour skip
*/
onTourSkip() {
// Save current progress but don't mark as completed
console.log('[TourManager] Tour skipped');
this.isActive = false;
}
}
window.TourManager = TourManager;
console.log('[TourManager] Module loaded');
})(window);
/**
* Tour Manager
* Orchestrates the onboarding tour using Driver.js
*/
(function(window) {
'use strict';
class TourManager {
constructor(progressTracker, themeAdapter, dnsTemplateSelector) {
this.progressTracker = progressTracker;
this.themeAdapter = themeAdapter;
this.dnsTemplateSelector = dnsTemplateSelector;
this.driver = null;
this.currentStepIndex = 0;
this.isActive = false;
this.resizeHandler = null;
this.layoutChangeHandler = null;
}
/**
* Initialize Driver.js with theme-aware configuration
*/
async initializeDriver() {
// Driver.js v1.x IIFE: window.driver.js.driver is the factory function
const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver;
if (typeof driverFactory !== 'function') {
console.error('[TourManager] Driver.js not loaded or invalid. window.driver:', window.driver);
return false;
}
const themeConfig = this.themeAdapter.getDriverTheme();
this.driver = driverFactory({
showProgress: true,
showButtons: ['next', 'previous', 'close'],
allowClose: true,
overlayClickNext: false,
overlayOpacity: 0,
stagePadding: 0,
stageRadius: 0,
allowKeyboardControl: true,
popoverClass: 'dashcaddy-popover',
onDestroyed: () => this.onTourComplete(),
onDestroyStarted: () => {
if (!this.progressTracker.isTourCompleted()) {
this.onTourSkip();
}
}
});
// Apply theme
this.themeAdapter.applyTheme(this.driver);
// Listen for theme changes
this.themeAdapter.onThemeChange(() => {
this.themeAdapter.applyTheme(this.driver);
});
// Set up dynamic repositioning
this.setupDynamicRepositioning();
return true;
}
/**
* Check if tour should auto-start
*/
shouldAutoStart() {
return !this.progressTracker.isTourCompleted() &&
this.progressTracker.getCurrentStep() === 0;
}
/**
* Start the onboarding tour
*/
async startTour() {
if (!this.driver) {
const initialized = await this.initializeDriver();
if (!initialized) return;
}
// Get active tooltips (filtered by conditions)
const allTooltips = window.TooltipDefinitions.getSortedTooltips();
// Filter out completed tooltips
const completedIds = this.progressTracker.getCompletedTooltips();
const activeTooltips = allTooltips.filter(t => !completedIds.includes(t.id));
if (activeTooltips.length === 0) {
console.log('[TourManager] No tooltips to show');
this.progressTracker.markTourCompleted();
return;
}
// Convert to Driver.js steps with navigation logic
const steps = activeTooltips.map((tooltip, index) => {
const isFirst = index === 0;
const isLast = index === activeTooltips.length - 1;
const step = {
element: tooltip.element,
popover: {
title: tooltip.popover.title,
description: tooltip.popover.description,
side: tooltip.popover.position || 'bottom',
align: tooltip.popover.align || 'start',
showButtons: this._getButtonsForStep(tooltip, isFirst, isLast),
showProgress: tooltip.popover.showProgress !== false,
onNextClick: () => {
this.progressTracker.markTooltipCompleted(tooltip.id);
this.progressTracker.setCurrentStep(index + 1);
this.currentStepIndex = index + 1;
this.driver.moveNext();
},
onPrevClick: () => {
this.progressTracker.setCurrentStep(Math.max(0, index - 1));
this.currentStepIndex = Math.max(0, index - 1);
this.driver.movePrevious();
},
onCloseClick: () => {
this.skipTour();
}
}
};
// Add custom handlers for DNS tooltip
if (tooltip.id === 'dns-priority' && this.dnsTemplateSelector) {
step.popover.onSetupNowClick = () => {
console.log('[TourManager] Opening DNS template selector');
this.dnsTemplateSelector.showTemplateSelector();
// Mark tooltip as completed and move to next
this.progressTracker.markTooltipCompleted(tooltip.id);
this.progressTracker.setCurrentStep(index + 1);
this.currentStepIndex = index + 1;
this.driver.moveNext();
};
step.popover.onLaterClick = () => {
console.log('[TourManager] DNS setup deferred');
this.progressTracker.markDnsSetupDeferred();
// Mark tooltip as completed and move to next
this.progressTracker.markTooltipCompleted(tooltip.id);
this.progressTracker.setCurrentStep(index + 1);
this.currentStepIndex = index + 1;
this.driver.moveNext();
};
}
return step;
});
this.isActive = true;
this.driver.setSteps(steps);
this.driver.drive();
}
/**
* Resume tour from last step
*/
async resumeTour() {
const currentStep = this.progressTracker.getCurrentStep();
if (currentStep > 0) {
await this.startTour();
// Driver.js will start from beginning, we'd need to skip to current step
// This is a simplified implementation
} else {
await this.startTour();
}
}
/**
* Skip the entire tour
*/
skipTour() {
if (this.driver) {
this.driver.destroy();
}
this.cleanupDynamicRepositioning();
this.isActive = false;
}
/**
* Restart tour from beginning
*/
async restartTour() {
this.progressTracker.resetProgress();
await this.startTour();
}
/**
* Show a specific tooltip by ID
*/
async showTooltip(tooltipId) {
const tooltip = window.TooltipDefinitions.getTooltipById(tooltipId);
if (!tooltip) {
console.error(`[TourManager] Tooltip not found: ${tooltipId}`);
return;
}
if (!this.driver) {
await this.initializeDriver();
}
const step = {
element: tooltip.element,
popover: {
title: tooltip.popover.title,
description: tooltip.popover.description,
side: tooltip.popover.position || 'bottom',
align: tooltip.popover.align || 'start'
}
};
this.driver.highlight(step);
}
/**
* Show "What's New" tour - only tooltips marked as new features
*/
async showWhatsNew() {
if (!this.driver) {
const initialized = await this.initializeDriver();
if (!initialized) return;
}
// Get only new feature tooltips
const newFeatureTooltips = window.TooltipDefinitions.getNewFeatureTooltips();
if (newFeatureTooltips.length === 0) {
console.log('[TourManager] No new features to show');
return;
}
console.log(`[TourManager] Showing ${newFeatureTooltips.length} new features`);
// Convert to Driver.js steps
const steps = newFeatureTooltips.map((tooltip, index) => {
const isFirst = index === 0;
const isLast = index === newFeatureTooltips.length - 1;
return {
element: tooltip.element,
popover: {
title: `✨ NEW: ${tooltip.popover.title}`,
description: tooltip.popover.description,
side: tooltip.popover.position || 'bottom',
align: tooltip.popover.align || 'start',
showButtons: this._getButtonsForStep(tooltip, isFirst, isLast),
showProgress: true,
onNextClick: () => {
this.driver.moveNext();
},
onPrevClick: () => {
this.driver.movePrevious();
},
onCloseClick: () => {
this.skipTour();
}
}
};
});
this.isActive = true;
this.driver.setSteps(steps);
this.driver.drive();
}
/**
* Set up dynamic repositioning for window resize and layout changes
*/
setupDynamicRepositioning() {
// Window resize handler with debouncing
let resizeTimeout;
this.resizeHandler = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
if (this.isActive && this.driver) {
console.log('[TourManager] Window resized, repositioning tooltip');
this.driver.refresh();
}
}, 150); // Debounce for 150ms
};
// Layout change handler (for theme changes, DOM mutations)
this.layoutChangeHandler = () => {
if (this.isActive && this.driver) {
console.log('[TourManager] Layout changed, repositioning tooltip');
// Small delay to allow layout to settle
setTimeout(() => {
if (this.driver) {
this.driver.refresh();
}
}, 100);
}
};
// Add event listeners
window.addEventListener('resize', this.resizeHandler);
// Listen for theme changes (already handled by ThemeAdapter, but also trigger reposition)
this.themeAdapter.onThemeChange(this.layoutChangeHandler);
}
/**
* Clean up dynamic repositioning listeners
*/
cleanupDynamicRepositioning() {
if (this.resizeHandler) {
window.removeEventListener('resize', this.resizeHandler);
}
}
/**
* Get buttons to show for a specific step
* @private
*/
_getButtonsForStep(tooltip, isFirst, isLast) {
// Check if tooltip has custom buttons defined
if (tooltip.popover.showButtons) {
return tooltip.popover.showButtons;
}
// Default button configuration
const buttons = [];
if (!isFirst) {
buttons.push('previous');
}
if (!isLast) {
buttons.push('next');
} else {
buttons.push('close');
}
return buttons;
}
/**
* Handle tour completion
*/
onTourComplete() {
this.progressTracker.markTourCompleted();
this.isActive = false;
console.log('[TourManager] Tour completed');
}
/**
* Handle tour skip
*/
onTourSkip() {
// Save current progress but don't mark as completed
console.log('[TourManager] Tour skipped');
this.isActive = false;
}
}
window.TourManager = TourManager;
console.log('[TourManager] Module loaded');
})(window);
+49 -17
View File
@@ -3,6 +3,12 @@ const path = require('path');
const crypto = require('crypto');
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 DIST = path.join(__dirname, 'dist');
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`);
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');
@@ -197,7 +216,11 @@ async function build() {
// the SW's activate handler wipes all older caches, so users never get
// stuck on stale precached bundles after a release.
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');
for (const name of Object.keys(bundles)) {
hash.update(fs.readFileSync(path.join(DIST, name)));
@@ -214,20 +237,29 @@ function updateServiceWorkerCache() {
}
// Watch mode
if (process.argv.includes('--watch')) {
console.log(' Watching for changes...\n');
build();
// DC-119: only auto-run when invoked directly (`node build.js`). Requiring
// build.js as a module (as tests/build-determinism.test.js does, to pin the
// 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');
let debounce = null;
fs.watch(jsDir, { recursive: true }, (event, filename) => {
if (!filename || !filename.endsWith('.js')) return;
clearTimeout(debounce);
debounce = setTimeout(() => {
console.log(` Changed: ${filename}`);
build();
}, 200);
});
} else {
build();
const jsDir = path.join(__dirname, 'js');
let debounce = null;
fs.watch(jsDir, { recursive: true }, (event, filename) => {
if (!filename || !filename.endsWith('.js')) return;
clearTimeout(debounce);
debounce = setTimeout(() => {
console.log(` Changed: ${filename}`);
build();
}, 200);
});
} else {
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",
"version": "1.0.0",
"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": {
@@ -453,6 +640,97 @@
"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": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -494,6 +772,268 @@
"@esbuild/win32-ia32": "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"
},
"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 = [
'/',
'/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');
});