mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-24 09:51:09 -05:00
Add secret management guide, client-side performance monitoring, and search accessibility enhancements
- Introduced a comprehensive Secret Management Guide detailing best practices, secret classification, development setup, production management, rotation procedures, and emergency protocols. - Implemented a client-side performance monitoring script to track various metrics including page load performance, paint metrics, layout shifts, and memory usage. - Enhanced search accessibility with keyboard navigation support for search results, ensuring compliance with WCAG standards and improving user experience.
This commit is contained in:
@@ -518,6 +518,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Accessibility - Forced Colors Mode (Windows High Contrast)
|
||||
================================================================= */
|
||||
|
||||
@media (forced-colors: active) {
|
||||
/* Ensure interactive elements have visible outlines */
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
[role="button"],
|
||||
[role="link"],
|
||||
[tabindex="0"] {
|
||||
outline: 2px solid ButtonText;
|
||||
}
|
||||
|
||||
/* Enhanced focus indicators for forced colors mode */
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
[role="menuitem"]:focus-visible,
|
||||
[role="option"]:focus-visible,
|
||||
[tabindex="0"]:focus-visible {
|
||||
outline: 3px solid Highlight;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Ensure disabled states are visible */
|
||||
[disabled],
|
||||
[aria-disabled="true"] {
|
||||
outline-style: dashed;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Accessibility - Screen Reader Utilities
|
||||
================================================================= */
|
||||
@@ -1011,6 +1049,22 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Enhanced focus ring for complex components */
|
||||
.focus-ring-thick:focus-visible {
|
||||
outline: 4px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Focus visible for custom interactive elements (ARIA roles) */
|
||||
[role="button"]:focus-visible,
|
||||
[role="menuitem"]:focus-visible,
|
||||
[role="option"]:focus-visible,
|
||||
[role="tab"]:focus-visible,
|
||||
[role="listitem"]:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Skip link styling */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
@@ -1126,4 +1180,68 @@
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================================================================
|
||||
Lazy Loading Image Utilities
|
||||
================================================================= */
|
||||
|
||||
/* Base lazy image styles */
|
||||
img.lazy {
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-300) var(--ease-out);
|
||||
}
|
||||
|
||||
img.lazy-loading {
|
||||
opacity: 0.5;
|
||||
filter: blur(5px);
|
||||
transition: opacity var(--duration-300) var(--ease-out),
|
||||
filter var(--duration-300) var(--ease-out);
|
||||
}
|
||||
|
||||
img.lazy-loaded {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
img.lazy-error {
|
||||
opacity: 0.3;
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
/* Placeholder styling for lazy images */
|
||||
img[data-src] {
|
||||
background-color: var(--color-muted);
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--color-muted) 0%,
|
||||
var(--color-secondary-200) 50%,
|
||||
var(--color-muted) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
img[data-src]:not(.lazy-loaded) {
|
||||
animation: skeleton-loading 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Blur-up placeholder effect */
|
||||
.blur-up {
|
||||
filter: blur(10px);
|
||||
transition: filter var(--duration-500) var(--ease-out);
|
||||
}
|
||||
|
||||
.blur-up.lazy-loaded {
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,336 @@
|
||||
// Lightbox Functionality
|
||||
/**
|
||||
* ThrillWiki Performance Optimization Module
|
||||
*
|
||||
* Features:
|
||||
* - Advanced lazy loading with Intersection Observer
|
||||
* - Image preloading and progressive loading
|
||||
* - Dynamic module loading (code splitting)
|
||||
* - Memory leak prevention utilities
|
||||
* - Event listener cleanup management
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Event Manager - Memory Leak Prevention
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* EventManager class for preventing memory leaks from orphaned event listeners.
|
||||
* Use this for dynamic elements that may be removed from the DOM.
|
||||
*/
|
||||
class EventManager {
|
||||
constructor() {
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event listener with automatic cleanup tracking
|
||||
* @param {Element} element - The DOM element
|
||||
* @param {string} event - Event type (e.g., 'click')
|
||||
* @param {Function} handler - Event handler function
|
||||
* @param {Object} options - Event listener options
|
||||
*/
|
||||
addEventListener(element, event, handler, options = {}) {
|
||||
if (!element) return;
|
||||
|
||||
if (!this.listeners.has(element)) {
|
||||
this.listeners.set(element, []);
|
||||
}
|
||||
this.listeners.get(element).push({ event, handler, options });
|
||||
element.addEventListener(event, handler, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all tracked event listeners for an element
|
||||
* @param {Element} element - The DOM element to clean up
|
||||
*/
|
||||
removeListeners(element) {
|
||||
if (!this.listeners.has(element)) return;
|
||||
|
||||
const handlers = this.listeners.get(element);
|
||||
handlers.forEach(({ event, handler, options }) => {
|
||||
element.removeEventListener(event, handler, options);
|
||||
});
|
||||
this.listeners.delete(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all tracked event listeners
|
||||
*/
|
||||
cleanup() {
|
||||
this.listeners.forEach((handlers, element) => {
|
||||
handlers.forEach(({ event, handler, options }) => {
|
||||
element.removeEventListener(event, handler, options);
|
||||
});
|
||||
});
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Global event manager instance
|
||||
window.ThrillWikiEventManager = new EventManager();
|
||||
|
||||
// =============================================================================
|
||||
// Advanced Lazy Loading with Intersection Observer
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* LazyLoader class for efficient image lazy loading using Intersection Observer.
|
||||
* Supports progressive loading with blur-up placeholder technique.
|
||||
*/
|
||||
class LazyLoader {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
rootMargin: options.rootMargin || '50px 0px',
|
||||
threshold: options.threshold || 0.01,
|
||||
loadingClass: options.loadingClass || 'lazy-loading',
|
||||
loadedClass: options.loadedClass || 'lazy-loaded',
|
||||
errorClass: options.errorClass || 'lazy-error',
|
||||
};
|
||||
|
||||
this.observer = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Check for Intersection Observer support
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
// Fallback: load all images immediately
|
||||
this.loadAllImages();
|
||||
return;
|
||||
}
|
||||
|
||||
this.observer = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this.loadImage(entry.target);
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
rootMargin: this.options.rootMargin,
|
||||
threshold: this.options.threshold,
|
||||
});
|
||||
|
||||
// Observe all lazy images
|
||||
this.observeImages();
|
||||
}
|
||||
|
||||
observeImages() {
|
||||
const lazyImages = document.querySelectorAll('img[data-src], img.lazy');
|
||||
lazyImages.forEach(img => {
|
||||
if (this.observer) {
|
||||
this.observer.observe(img);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadImage(img) {
|
||||
const src = img.dataset.src || img.getAttribute('data-src');
|
||||
const srcset = img.dataset.srcset || img.getAttribute('data-srcset');
|
||||
|
||||
if (!src && !srcset) return;
|
||||
|
||||
img.classList.add(this.options.loadingClass);
|
||||
|
||||
// Create a temporary image to preload
|
||||
const tempImage = new Image();
|
||||
|
||||
tempImage.onload = () => {
|
||||
if (src) img.src = src;
|
||||
if (srcset) img.srcset = srcset;
|
||||
|
||||
img.classList.remove(this.options.loadingClass);
|
||||
img.classList.add(this.options.loadedClass);
|
||||
img.classList.remove('lazy');
|
||||
|
||||
// Trigger custom event for tracking
|
||||
img.dispatchEvent(new CustomEvent('lazyloaded', { bubbles: true }));
|
||||
};
|
||||
|
||||
tempImage.onerror = () => {
|
||||
img.classList.remove(this.options.loadingClass);
|
||||
img.classList.add(this.options.errorClass);
|
||||
console.warn('Failed to load image:', src);
|
||||
};
|
||||
|
||||
// Start loading
|
||||
tempImage.src = src || '';
|
||||
if (srcset) tempImage.srcset = srcset;
|
||||
}
|
||||
|
||||
loadAllImages() {
|
||||
const lazyImages = document.querySelectorAll('img[data-src], img.lazy');
|
||||
lazyImages.forEach(img => this.loadImage(img));
|
||||
}
|
||||
|
||||
// Add new images to observation (for dynamically added content)
|
||||
observe(img) {
|
||||
if (this.observer) {
|
||||
this.observer.observe(img);
|
||||
} else {
|
||||
this.loadImage(img);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
this.observer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Module Loading (Code Splitting)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* ModuleLoader for lazy loading JavaScript modules only when needed.
|
||||
* Reduces initial bundle size and improves page load time.
|
||||
*/
|
||||
class ModuleLoader {
|
||||
constructor() {
|
||||
this.loadedModules = new Set();
|
||||
this.loadingPromises = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically load a JavaScript module
|
||||
* @param {string} modulePath - Path to the module
|
||||
* @returns {Promise} - Resolves when module is loaded
|
||||
*/
|
||||
async load(modulePath) {
|
||||
// Return cached if already loaded
|
||||
if (this.loadedModules.has(modulePath)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Return existing promise if currently loading
|
||||
if (this.loadingPromises.has(modulePath)) {
|
||||
return this.loadingPromises.get(modulePath);
|
||||
}
|
||||
|
||||
// Create loading promise
|
||||
const loadPromise = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = modulePath;
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => {
|
||||
this.loadedModules.add(modulePath);
|
||||
this.loadingPromises.delete(modulePath);
|
||||
resolve();
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
this.loadingPromises.delete(modulePath);
|
||||
reject(new Error(`Failed to load module: ${modulePath}`));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
this.loadingPromises.set(modulePath, loadPromise);
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally load a module if an element exists
|
||||
* @param {string} selector - CSS selector to check
|
||||
* @param {string} modulePath - Path to the module to load
|
||||
*/
|
||||
async loadIfNeeded(selector, modulePath) {
|
||||
if (document.querySelector(selector)) {
|
||||
await this.load(modulePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global module loader instance
|
||||
window.ThrillWikiModuleLoader = new ModuleLoader();
|
||||
|
||||
// =============================================================================
|
||||
// Lightbox Functionality (Alpine.js Component)
|
||||
// =============================================================================
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('lightbox', () => ({
|
||||
isOpen: false,
|
||||
imgSrc: '',
|
||||
imgAlt: '',
|
||||
|
||||
|
||||
open(event) {
|
||||
const img = event.target.tagName === 'IMG' ? event.target : event.target.querySelector('img');
|
||||
if (img) {
|
||||
this.imgSrc = img.src;
|
||||
// Use full-size image if available via data-full-src
|
||||
this.imgSrc = img.dataset.fullSrc || img.src;
|
||||
this.imgAlt = img.alt;
|
||||
this.isOpen = true;
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.imgSrc = '';
|
||||
this.imgAlt = '';
|
||||
document.body.style.overflow = '';
|
||||
},
|
||||
|
||||
// Cleanup method to prevent memory leaks
|
||||
destroy() {
|
||||
this.close();
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
// Add lazy loading to all images
|
||||
// =============================================================================
|
||||
// Initialization
|
||||
// =============================================================================
|
||||
|
||||
// Global lazy loader instance
|
||||
let lazyLoader = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize lazy loader
|
||||
lazyLoader = new LazyLoader();
|
||||
window.ThrillWikiLazyLoader = lazyLoader;
|
||||
|
||||
// Add native lazy loading to images that don't have it
|
||||
const images = document.querySelectorAll('img:not([loading])');
|
||||
images.forEach(img => {
|
||||
img.setAttribute('loading', 'lazy');
|
||||
// Set eager for above-the-fold images (first 3 visible images)
|
||||
const rect = img.getBoundingClientRect();
|
||||
const isAboveFold = rect.top < window.innerHeight && rect.top >= 0;
|
||||
img.setAttribute('loading', isAboveFold ? 'eager' : 'lazy');
|
||||
});
|
||||
|
||||
// Handle HTMX content updates
|
||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
// Re-observe any new lazy images after HTMX updates
|
||||
if (lazyLoader) {
|
||||
const newImages = event.detail.target.querySelectorAll('img[data-src], img.lazy');
|
||||
newImages.forEach(img => lazyLoader.observe(img));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (window.ThrillWikiEventManager) {
|
||||
window.ThrillWikiEventManager.cleanup();
|
||||
}
|
||||
if (lazyLoader) {
|
||||
lazyLoader.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Export for use in other modules
|
||||
// =============================================================================
|
||||
|
||||
window.ThrillWikiOptimization = {
|
||||
EventManager,
|
||||
LazyLoader,
|
||||
ModuleLoader,
|
||||
};
|
||||
|
||||
411
static/js/performance-monitor.js
Normal file
411
static/js/performance-monitor.js
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* ThrillWiki Client-Side Performance Monitoring
|
||||
*
|
||||
* This module provides comprehensive client-side performance metrics collection
|
||||
* and monitoring. It tracks:
|
||||
* - Page load performance (Navigation Timing API)
|
||||
* - Paint metrics (First Paint, First Contentful Paint)
|
||||
* - Layout shifts (Cumulative Layout Shift)
|
||||
* - Largest Contentful Paint
|
||||
* - First Input Delay
|
||||
* - Memory usage (Chrome only)
|
||||
* - Long tasks (> 50ms)
|
||||
* - Resource timing
|
||||
*
|
||||
* Usage:
|
||||
* Include this script after DOMContentLoaded.
|
||||
* Metrics are logged to console in development and can be sent to a backend endpoint.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
enableConsoleLogging: true, // Log to console in development
|
||||
enableBeacon: false, // Send metrics to backend (set endpoint below)
|
||||
beaconEndpoint: '/api/v1/metrics/performance/',
|
||||
memoryCheckInterval: 30000, // Check memory every 30 seconds
|
||||
memoryWarningThreshold: 0.9, // Warn at 90% memory usage
|
||||
longTaskThreshold: 50, // Long task threshold in ms
|
||||
reportDelay: 3000, // Delay before reporting metrics
|
||||
};
|
||||
|
||||
// Metrics storage
|
||||
const metrics = {
|
||||
navigation: {},
|
||||
paint: {},
|
||||
resources: [],
|
||||
longTasks: [],
|
||||
layoutShifts: [],
|
||||
largestContentfulPaint: null,
|
||||
firstInputDelay: null,
|
||||
memory: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Collect Navigation Timing metrics
|
||||
*/
|
||||
function collectNavigationTiming() {
|
||||
if (!performance.getEntriesByType) return;
|
||||
|
||||
const navEntries = performance.getEntriesByType('navigation');
|
||||
if (navEntries.length === 0) return;
|
||||
|
||||
const nav = navEntries[0];
|
||||
metrics.navigation = {
|
||||
// DNS lookup time
|
||||
dnsLookup: nav.domainLookupEnd - nav.domainLookupStart,
|
||||
// TCP connection time
|
||||
tcpConnect: nav.connectEnd - nav.connectStart,
|
||||
// TLS handshake time (if HTTPS)
|
||||
tlsHandshake: nav.secureConnectionStart > 0
|
||||
? nav.connectEnd - nav.secureConnectionStart
|
||||
: 0,
|
||||
// Time to First Byte (TTFB)
|
||||
ttfb: nav.responseStart - nav.requestStart,
|
||||
// Response download time
|
||||
responseTime: nav.responseEnd - nav.responseStart,
|
||||
// DOM parsing time
|
||||
domParsing: nav.domContentLoadedEventEnd - nav.domInteractive,
|
||||
// DOM Content Loaded
|
||||
domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
|
||||
// Full page load
|
||||
loadComplete: nav.loadEventEnd - nav.startTime,
|
||||
// Redirect time (if any)
|
||||
redirectTime: nav.redirectEnd - nav.redirectStart,
|
||||
// Transfer size
|
||||
transferSize: nav.transferSize,
|
||||
// Encoded body size
|
||||
encodedBodySize: nav.encodedBodySize,
|
||||
// Decoded body size
|
||||
decodedBodySize: nav.decodedBodySize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect Paint Timing metrics
|
||||
*/
|
||||
function collectPaintTiming() {
|
||||
if (!performance.getEntriesByType) return;
|
||||
|
||||
const paintEntries = performance.getEntriesByType('paint');
|
||||
paintEntries.forEach(entry => {
|
||||
if (entry.name === 'first-paint') {
|
||||
metrics.paint.firstPaint = entry.startTime;
|
||||
} else if (entry.name === 'first-contentful-paint') {
|
||||
metrics.paint.firstContentfulPaint = entry.startTime;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe Largest Contentful Paint
|
||||
*/
|
||||
function observeLCP() {
|
||||
if (!('PerformanceObserver' in window)) return;
|
||||
|
||||
try {
|
||||
const lcpObserver = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries();
|
||||
const lastEntry = entries[entries.length - 1];
|
||||
metrics.largestContentfulPaint = {
|
||||
time: lastEntry.startTime,
|
||||
element: lastEntry.element ? lastEntry.element.tagName : 'unknown',
|
||||
size: lastEntry.size,
|
||||
url: lastEntry.url || null,
|
||||
};
|
||||
});
|
||||
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
|
||||
} catch (e) {
|
||||
// LCP not supported
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe First Input Delay
|
||||
*/
|
||||
function observeFID() {
|
||||
if (!('PerformanceObserver' in window)) return;
|
||||
|
||||
try {
|
||||
const fidObserver = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries();
|
||||
entries.forEach(entry => {
|
||||
if (!metrics.firstInputDelay) {
|
||||
metrics.firstInputDelay = {
|
||||
delay: entry.processingStart - entry.startTime,
|
||||
eventType: entry.name,
|
||||
target: entry.target ? entry.target.tagName : 'unknown',
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
fidObserver.observe({ type: 'first-input', buffered: true });
|
||||
} catch (e) {
|
||||
// FID not supported
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe Cumulative Layout Shift
|
||||
*/
|
||||
function observeCLS() {
|
||||
if (!('PerformanceObserver' in window)) return;
|
||||
|
||||
let clsValue = 0;
|
||||
let sessionValue = 0;
|
||||
let sessionEntries = [];
|
||||
|
||||
try {
|
||||
const clsObserver = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries();
|
||||
entries.forEach(entry => {
|
||||
// Only count layout shifts without recent user input
|
||||
if (!entry.hadRecentInput) {
|
||||
const firstSessionEntry = sessionEntries[0];
|
||||
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
|
||||
|
||||
// Start a new session if gap > 1 second or session > 5 seconds
|
||||
if (sessionValue &&
|
||||
(entry.startTime - lastSessionEntry.startTime > 1000 ||
|
||||
entry.startTime - firstSessionEntry.startTime > 5000)) {
|
||||
clsValue = Math.max(clsValue, sessionValue);
|
||||
sessionValue = entry.value;
|
||||
sessionEntries = [entry];
|
||||
} else {
|
||||
sessionValue += entry.value;
|
||||
sessionEntries.push(entry);
|
||||
}
|
||||
|
||||
metrics.layoutShifts.push({
|
||||
value: entry.value,
|
||||
time: entry.startTime,
|
||||
sources: entry.sources ? entry.sources.length : 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
clsObserver.observe({ type: 'layout-shift', buffered: true });
|
||||
} catch (e) {
|
||||
// CLS not supported
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe Long Tasks
|
||||
*/
|
||||
function observeLongTasks() {
|
||||
if (!('PerformanceObserver' in window)) return;
|
||||
|
||||
try {
|
||||
const longTaskObserver = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries();
|
||||
entries.forEach(entry => {
|
||||
if (entry.duration > CONFIG.longTaskThreshold) {
|
||||
metrics.longTasks.push({
|
||||
duration: entry.duration,
|
||||
startTime: entry.startTime,
|
||||
name: entry.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
longTaskObserver.observe({ entryTypes: ['longtask'] });
|
||||
} catch (e) {
|
||||
// Long tasks not supported
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor memory usage (Chrome only)
|
||||
*/
|
||||
function monitorMemory() {
|
||||
if (!performance.memory) return;
|
||||
|
||||
const checkMemory = () => {
|
||||
const memoryInfo = performance.memory;
|
||||
const usedRatio = memoryInfo.usedJSHeapSize / memoryInfo.jsHeapSizeLimit;
|
||||
|
||||
const memorySnapshot = {
|
||||
timestamp: Date.now(),
|
||||
usedHeapSize: memoryInfo.usedJSHeapSize,
|
||||
totalHeapSize: memoryInfo.totalJSHeapSize,
|
||||
heapSizeLimit: memoryInfo.jsHeapSizeLimit,
|
||||
usedRatio: usedRatio,
|
||||
};
|
||||
|
||||
metrics.memory.push(memorySnapshot);
|
||||
|
||||
// Keep only last 10 snapshots
|
||||
if (metrics.memory.length > 10) {
|
||||
metrics.memory.shift();
|
||||
}
|
||||
|
||||
// Warn if memory usage is high
|
||||
if (usedRatio > CONFIG.memoryWarningThreshold) {
|
||||
console.warn('ThrillWiki Performance: High memory usage detected', {
|
||||
usedPercent: (usedRatio * 100).toFixed(1) + '%',
|
||||
usedMB: (memoryInfo.usedJSHeapSize / 1024 / 1024).toFixed(2) + ' MB',
|
||||
limitMB: (memoryInfo.jsHeapSizeLimit / 1024 / 1024).toFixed(2) + ' MB',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check
|
||||
checkMemory();
|
||||
|
||||
// Periodic checks
|
||||
setInterval(checkMemory, CONFIG.memoryCheckInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect resource timing
|
||||
*/
|
||||
function collectResourceTiming() {
|
||||
if (!performance.getEntriesByType) return;
|
||||
|
||||
const resources = performance.getEntriesByType('resource');
|
||||
metrics.resources = resources.slice(0, 50).map(resource => ({
|
||||
name: resource.name.split('?')[0], // Remove query params
|
||||
type: resource.initiatorType,
|
||||
duration: resource.duration,
|
||||
transferSize: resource.transferSize,
|
||||
startTime: resource.startTime,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate performance summary
|
||||
*/
|
||||
function generateSummary() {
|
||||
const summary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
navigation: metrics.navigation,
|
||||
paint: metrics.paint,
|
||||
lcp: metrics.largestContentfulPaint,
|
||||
fid: metrics.firstInputDelay,
|
||||
cls: metrics.layoutShifts.reduce((sum, shift) => sum + shift.value, 0),
|
||||
longTaskCount: metrics.longTasks.length,
|
||||
longTaskTotalTime: metrics.longTasks.reduce((sum, task) => sum + task.duration, 0),
|
||||
resourceCount: metrics.resources.length,
|
||||
totalTransferSize: metrics.resources.reduce((sum, r) => sum + (r.transferSize || 0), 0),
|
||||
};
|
||||
|
||||
// Calculate Web Vitals ratings
|
||||
summary.webVitals = {
|
||||
lcp: rateMetric('lcp', summary.lcp?.time),
|
||||
fid: rateMetric('fid', summary.fid?.delay),
|
||||
cls: rateMetric('cls', summary.cls),
|
||||
};
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate a metric according to Web Vitals thresholds
|
||||
*/
|
||||
function rateMetric(metric, value) {
|
||||
if (value === null || value === undefined) return 'unknown';
|
||||
|
||||
const thresholds = {
|
||||
lcp: { good: 2500, needsImprovement: 4000 },
|
||||
fid: { good: 100, needsImprovement: 300 },
|
||||
cls: { good: 0.1, needsImprovement: 0.25 },
|
||||
};
|
||||
|
||||
const threshold = thresholds[metric];
|
||||
if (!threshold) return 'unknown';
|
||||
|
||||
if (value <= threshold.good) return 'good';
|
||||
if (value <= threshold.needsImprovement) return 'needs-improvement';
|
||||
return 'poor';
|
||||
}
|
||||
|
||||
/**
|
||||
* Log metrics to console
|
||||
*/
|
||||
function logMetrics() {
|
||||
if (!CONFIG.enableConsoleLogging) return;
|
||||
|
||||
const summary = generateSummary();
|
||||
|
||||
console.group('ThrillWiki Performance Metrics');
|
||||
console.log('Page Load:', summary.navigation.loadComplete?.toFixed(0) + 'ms');
|
||||
console.log('TTFB:', summary.navigation.ttfb?.toFixed(0) + 'ms');
|
||||
console.log('First Paint:', summary.paint.firstPaint?.toFixed(0) + 'ms');
|
||||
console.log('First Contentful Paint:', summary.paint.firstContentfulPaint?.toFixed(0) + 'ms');
|
||||
console.log('Largest Contentful Paint:', summary.lcp?.time?.toFixed(0) + 'ms', `(${summary.webVitals.lcp})`);
|
||||
console.log('First Input Delay:', summary.fid?.delay?.toFixed(0) + 'ms', `(${summary.webVitals.fid})`);
|
||||
console.log('Cumulative Layout Shift:', summary.cls?.toFixed(3), `(${summary.webVitals.cls})`);
|
||||
console.log('Long Tasks:', summary.longTaskCount, `(${summary.longTaskTotalTime.toFixed(0)}ms total)`);
|
||||
console.log('Resources:', summary.resourceCount, `(${(summary.totalTransferSize / 1024).toFixed(0)}KB)`);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send metrics to backend
|
||||
*/
|
||||
function sendMetrics() {
|
||||
if (!CONFIG.enableBeacon || !navigator.sendBeacon) return;
|
||||
|
||||
const summary = generateSummary();
|
||||
const blob = new Blob([JSON.stringify(summary)], { type: 'application/json' });
|
||||
|
||||
try {
|
||||
navigator.sendBeacon(CONFIG.beaconEndpoint, blob);
|
||||
} catch (e) {
|
||||
console.warn('Failed to send performance metrics:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize performance monitoring
|
||||
*/
|
||||
function init() {
|
||||
// Start observers early
|
||||
observeLCP();
|
||||
observeFID();
|
||||
observeCLS();
|
||||
observeLongTasks();
|
||||
|
||||
// Collect metrics after page load
|
||||
window.addEventListener('load', () => {
|
||||
// Delay to ensure all metrics are available
|
||||
setTimeout(() => {
|
||||
collectNavigationTiming();
|
||||
collectPaintTiming();
|
||||
collectResourceTiming();
|
||||
logMetrics();
|
||||
sendMetrics();
|
||||
}, CONFIG.reportDelay);
|
||||
});
|
||||
|
||||
// Start memory monitoring
|
||||
monitorMemory();
|
||||
|
||||
// Report on page unload
|
||||
window.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
sendMetrics();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Expose API
|
||||
window.ThrillWikiPerformance = {
|
||||
getMetrics: () => metrics,
|
||||
getSummary: generateSummary,
|
||||
logMetrics: logMetrics,
|
||||
};
|
||||
|
||||
// Initialize
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
220
static/js/search-accessibility.js
Normal file
220
static/js/search-accessibility.js
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Search Results Keyboard Navigation
|
||||
* Handles Arrow Up/Down, Enter, and Escape keys for accessible search
|
||||
*
|
||||
* This module enhances search inputs with keyboard navigation for WCAG compliance:
|
||||
* - Arrow Down: Navigate to next search result
|
||||
* - Arrow Up: Navigate to previous search result (or back to input)
|
||||
* - Enter: Select current result (navigate to link)
|
||||
* - Escape: Close search results and blur input
|
||||
*
|
||||
* Usage:
|
||||
* The script automatically initializes on DOMContentLoaded for all search inputs
|
||||
* with hx-target attribute pointing to a results container.
|
||||
*
|
||||
* HTMX Integration:
|
||||
* Results should include role="option" on each selectable item.
|
||||
* The script listens for htmx:afterSwap to reinitialize when results change.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Initialize keyboard navigation for a search input
|
||||
* @param {HTMLInputElement} input - The search input element
|
||||
*/
|
||||
function initSearchAccessibility(input) {
|
||||
const targetSelector = input.getAttribute('hx-target');
|
||||
if (!targetSelector) return;
|
||||
|
||||
const resultsContainer = document.querySelector(targetSelector);
|
||||
if (!resultsContainer) return;
|
||||
|
||||
// Track if we've already initialized this input
|
||||
if (input.dataset.searchAccessibilityInit) return;
|
||||
input.dataset.searchAccessibilityInit = 'true';
|
||||
|
||||
let currentIndex = -1;
|
||||
|
||||
/**
|
||||
* Update visual and ARIA selection state for results
|
||||
* @param {NodeList} results - List of result option elements
|
||||
* @param {number} index - Index of currently selected item (-1 for none)
|
||||
*/
|
||||
function updateSelection(results, index) {
|
||||
results.forEach((result, i) => {
|
||||
if (i === index) {
|
||||
result.setAttribute('aria-selected', 'true');
|
||||
result.classList.add('bg-accent');
|
||||
result.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
} else {
|
||||
result.setAttribute('aria-selected', 'false');
|
||||
result.classList.remove('bg-accent');
|
||||
}
|
||||
});
|
||||
|
||||
// Update aria-activedescendant on the input
|
||||
if (index >= 0 && results[index]) {
|
||||
const resultId = results[index].id || `search-result-${index}`;
|
||||
if (!results[index].id) {
|
||||
results[index].id = resultId;
|
||||
}
|
||||
input.setAttribute('aria-activedescendant', resultId);
|
||||
} else {
|
||||
input.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce result count to screen readers
|
||||
* @param {number} count - Number of results found
|
||||
*/
|
||||
function announceResults(count) {
|
||||
const statusId = input.getAttribute('aria-describedby');
|
||||
if (statusId) {
|
||||
const statusEl = document.getElementById(statusId);
|
||||
if (statusEl) {
|
||||
if (count === 0) {
|
||||
statusEl.textContent = 'No results found';
|
||||
} else if (count === 1) {
|
||||
statusEl.textContent = '1 result found. Use arrow keys to navigate.';
|
||||
} else {
|
||||
statusEl.textContent = `${count} results found. Use arrow keys to navigate.`;
|
||||
}
|
||||
// Clear after a delay to allow re-announcement on new searches
|
||||
setTimeout(() => {
|
||||
statusEl.textContent = '';
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keydown events for navigation
|
||||
* @param {KeyboardEvent} e - The keyboard event
|
||||
*/
|
||||
function handleKeydown(e) {
|
||||
const results = resultsContainer.querySelectorAll('[role="option"]');
|
||||
|
||||
// If no results and not escape, let default behavior happen
|
||||
if (results.length === 0 && e.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (results.length > 0) {
|
||||
currentIndex = Math.min(currentIndex + 1, results.length - 1);
|
||||
updateSelection(results, currentIndex);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (currentIndex > 0) {
|
||||
currentIndex = currentIndex - 1;
|
||||
updateSelection(results, currentIndex);
|
||||
} else if (currentIndex === 0) {
|
||||
// Move back to input
|
||||
currentIndex = -1;
|
||||
updateSelection(results, currentIndex);
|
||||
input.focus();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
if (currentIndex >= 0 && results[currentIndex]) {
|
||||
e.preventDefault();
|
||||
const link = results[currentIndex].querySelector('a');
|
||||
if (link) {
|
||||
link.click();
|
||||
} else if (results[currentIndex].tagName === 'A') {
|
||||
results[currentIndex].click();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
// Clear results and reset
|
||||
resultsContainer.innerHTML = '';
|
||||
currentIndex = -1;
|
||||
input.blur();
|
||||
break;
|
||||
|
||||
case 'Home':
|
||||
if (results.length > 0) {
|
||||
e.preventDefault();
|
||||
currentIndex = 0;
|
||||
updateSelection(results, currentIndex);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'End':
|
||||
if (results.length > 0) {
|
||||
e.preventDefault();
|
||||
currentIndex = results.length - 1;
|
||||
updateSelection(results, currentIndex);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add keydown listener
|
||||
input.addEventListener('keydown', handleKeydown);
|
||||
|
||||
// Reset index when input changes
|
||||
input.addEventListener('input', function() {
|
||||
currentIndex = -1;
|
||||
});
|
||||
|
||||
// Reset on focus
|
||||
input.addEventListener('focus', function() {
|
||||
currentIndex = -1;
|
||||
});
|
||||
|
||||
// Listen for HTMX swap events to announce results
|
||||
resultsContainer.addEventListener('htmx:afterSwap', function() {
|
||||
currentIndex = -1;
|
||||
const results = resultsContainer.querySelectorAll('[role="option"]');
|
||||
announceResults(results.length);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all search inputs on the page
|
||||
*/
|
||||
function initAllSearchInputs() {
|
||||
const searchInputs = document.querySelectorAll('input[type="search"][hx-target], input[type="text"][hx-target][name="q"]');
|
||||
searchInputs.forEach(initSearchAccessibility);
|
||||
}
|
||||
|
||||
// Initialize on DOMContentLoaded
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initAllSearchInputs);
|
||||
} else {
|
||||
initAllSearchInputs();
|
||||
}
|
||||
|
||||
// Re-initialize after HTMX swaps that might add new search inputs
|
||||
document.body.addEventListener('htmx:afterSettle', function(evt) {
|
||||
// Only reinitialize if new search inputs were added
|
||||
if (evt.target.querySelector && evt.target.querySelector('input[type="search"], input[name="q"]')) {
|
||||
initAllSearchInputs();
|
||||
}
|
||||
});
|
||||
|
||||
// Also listen for status announcements from HTMX responses
|
||||
document.body.addEventListener('announceStatus', function(evt) {
|
||||
const announcer = document.getElementById('status-announcer');
|
||||
if (announcer && evt.detail && evt.detail.message) {
|
||||
announcer.textContent = evt.detail.message;
|
||||
setTimeout(() => {
|
||||
announcer.textContent = '';
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user