diff --git a/docs/plans/2026-03-24-modal-system.md b/docs/plans/2026-03-24-modal-system.md new file mode 100644 index 0000000..74ac4d3 --- /dev/null +++ b/docs/plans/2026-03-24-modal-system.md @@ -0,0 +1,1214 @@ +# Modal System Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace existing dialog implementations with a unified modal system providing glassmorphism backdrop, full accessibility, and smooth animations. + +**Architecture:** Single `ModalManager` class manages all modals with focus trap, keyboard handling, and dynamic backdrop. All 10 dialogs convert to unified HTML structure with new CSS classes. + +**Tech Stack:** Vanilla JavaScript, CSS custom properties (design tokens), no external dependencies + +--- + +## Task 1: Create ModalManager Class + +**Files:** +- Create: `src/utils/ModalManager.js` + +**Step 1: Create utils directory and ModalManager class** + +```javascript +/** + * ModalManager - Unified modal system with accessibility support + * @version 4.0.0 + */ +export class ModalManager { + #modal; + #backdrop; + #options; + #lastFocusedElement; + #focusableElements; + #eventListeners; + #isOpen; + + static #openModals = []; + + constructor(element, options = {}) { + this.#modal = typeof element === 'string' ? document.querySelector(element) : element; + this.#options = { + closeOnBackdrop: true, + closeOnEscape: true, + focusFirst: true, + onOpen: null, + onClose: null, + ...options + }; + this.#isOpen = false; + this.#eventListeners = []; + this.#init(); + } + + #init() { + // Ensure modal has required attributes + if (!this.#modal.hasAttribute('role')) { + this.#modal.setAttribute('role', 'dialog'); + } + if (!this.#modal.hasAttribute('aria-modal')) { + this.#modal.setAttribute('aria-modal', 'true'); + } + + // Find or create backdrop + this.#backdrop = this.#modal.querySelector('.modal-backdrop'); + + // Setup close triggers + this.#setupCloseTriggers(); + } + + #setupCloseTriggers() { + // Close button + const closeBtn = this.#modal.querySelector('.modal-close'); + if (closeBtn) { + const handler = (e) => { + e.preventDefault(); + this.close(); + }; + closeBtn.addEventListener('click', handler); + this.#eventListeners.push({ el: closeBtn, type: 'click', handler }); + } + + // Elements with data-close attribute + const closeTriggers = this.#modal.querySelectorAll('[data-close]'); + closeTriggers.forEach(el => { + if (el.classList.contains('modal-backdrop') && !this.#options.closeOnBackdrop) { + return; + } + const handler = (e) => { + e.preventDefault(); + this.close(); + }; + el.addEventListener('click', handler); + this.#eventListeners.push({ el, type: 'click', handler }); + }); + } + + #getFocusableElements() { + const selector = [ + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'a[href]', + '[tabindex]:not([tabindex="-1"])' + ].join(', '); + + return Array.from(this.#modal.querySelectorAll(selector)) + .filter(el => el.offsetParent !== null && !el.classList.contains('modal-backdrop')); + } + + #trapFocus(e) { + if (e.key !== 'Tab') return; + + const focusable = this.#getFocusableElements(); + if (focusable.length === 0) return; + + const firstEl = focusable[0]; + const lastEl = focusable[focusable.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === firstEl) { + e.preventDefault(); + lastEl.focus(); + } + } else { + if (document.activeElement === lastEl) { + e.preventDefault(); + firstEl.focus(); + } + } + } + + #handleKeydown(e) { + if (e.key === 'Escape' && this.#options.closeOnEscape) { + e.preventDefault(); + this.close(); + } + this.#trapFocus(e); + } + + open() { + if (this.#isOpen) return; + + // Store last focused element + this.#lastFocusedElement = document.activeElement; + + // Track open modals + ModalManager.#openModals.push(this); + + // Show modal + this.#modal.classList.add('open'); + this.#isOpen = true; + + // Prevent body scroll + document.body.style.overflow = 'hidden'; + + // Add keyboard listener + const keydownHandler = (e) => this.#handleKeydown(e); + document.addEventListener('keydown', keydownHandler); + this.#eventListeners.push({ el: document, type: 'keydown', handler: keydownHandler }); + + // Focus first element + if (this.#options.focusFirst) { + requestAnimationFrame(() => { + const focusable = this.#getFocusableElements(); + if (focusable.length > 0) { + focusable[0].focus(); + } + }); + } + + // Callback + if (this.#options.onOpen) { + this.#options.onOpen(this); + } + + // Dispatch custom event + this.#modal.dispatchEvent(new CustomEvent('modal:open')); + } + + close() { + if (!this.#isOpen) return; + + // Remove from open modals + const index = ModalManager.#openModals.indexOf(this); + if (index > -1) { + ModalManager.#openModals.splice(index, 1); + } + + // Hide modal + this.#modal.classList.remove('open'); + this.#isOpen = false; + + // Restore body scroll if no modals open + if (ModalManager.#openModals.length === 0) { + document.body.style.overflow = ''; + } + + // Remove keyboard listener + const keydownListener = this.#eventListeners.find( + l => l.el === document && l.type === 'keydown' + ); + if (keydownListener) { + document.removeEventListener('keydown', keydownListener.handler); + this.#eventListeners = this.#eventListeners.filter(l => l !== keydownListener); + } + + // Restore focus + if (this.#lastFocusedElement && typeof this.#lastFocusedElement.focus === 'function') { + this.#lastFocusedElement.focus(); + } + + // Callback + if (this.#options.onClose) { + this.#options.onClose(this); + } + + // Dispatch custom event + this.#modal.dispatchEvent(new CustomEvent('modal:close')); + } + + isOpen() { + return this.#isOpen; + } + + destroy() { + // Close if open + if (this.#isOpen) { + this.close(); + } + + // Remove all event listeners + this.#eventListeners.forEach(({ el, type, handler }) => { + el.removeEventListener(type, handler); + }); + this.#eventListeners = []; + } +} + +// Export for use in renderer +window.ModalManager = ModalManager; +``` + +**Step 2: Commit ModalManager** + +```bash +git add src/utils/ModalManager.js +git commit -m "feat: add ModalManager class for unified modal system" +``` + +--- + +## Task 2: Create Modal CSS Styles + +**Files:** +- Create: `src/styles/modal.css` + +**Step 1: Create modal.css with glassmorphism styles** + +```css +/** + * Modal System Styles + * Unified modal components with glassmorphism backdrop + * @version 4.0.0 + */ + +/* ============================================ + * Modal Backdrop - Glassmorphism + * ============================================ */ + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: var(--z-modal, 200); + cursor: pointer; +} + +/* ============================================ + * Modal Container + * ============================================ */ + +.modal { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: calc(var(--z-modal, 200) + 1); + opacity: 0; + visibility: hidden; + transition: opacity var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1)), + visibility var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1)); + padding: var(--spacing-4, 1rem); +} + +.modal.open { + opacity: 1; + visibility: visible; +} + +/* ============================================ + * Modal Content - With Animation + * ============================================ */ + +.modal-content { + background: hsl(var(--background, 0 0% 100%)); + border-radius: var(--radius-lg, 0.5rem); + box-shadow: var(--shadow-xl, 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)); + max-width: 90vw; + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; + transform: scale(0.95); + transition: transform var(--transition-normal, 200ms cubic-bezier(0.4, 0, 0.2, 1)); +} + +.modal.open .modal-content { + transform: scale(1); +} + +/* ============================================ + * Modal Header + * ============================================ */ + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem); + border-bottom: 1px solid hsl(var(--border, 214.3 31.8% 91.4%)); + background: hsl(var(--muted, 210 40% 96.1%)); +} + +.modal-header h3 { + margin: 0; + font-size: var(--text-lg, 1.125rem); + font-weight: var(--font-semibold, 600); + color: hsl(var(--foreground, 222.2 84% 4.9%)); +} + +/* ============================================ + * Modal Close Button + * ============================================ */ + +.modal-close { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: var(--radius, 0.5rem); + background: transparent; + color: hsl(var(--muted-foreground, 215.4 16.3% 46.9%)); + font-size: 24px; + line-height: 1; + cursor: pointer; + transition: background-color var(--transition-fast, 150ms), + color var(--transition-fast, 150ms); +} + +.modal-close:hover { + background: hsl(var(--accent, 210 40% 96.1%)); + color: hsl(var(--foreground, 222.2 84% 4.9%)); +} + +.modal-close:focus-visible { + outline: 2px solid hsl(var(--ring, 227 44% 52%)); + outline-offset: 2px; +} + +/* ============================================ + * Modal Body + * ============================================ */ + +.modal-body { + padding: var(--spacing-6, 1.5rem); + overflow-y: auto; + flex: 1; +} + +/* ============================================ + * Modal Footer + * ============================================ */ + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: var(--spacing-3, 0.75rem); + padding: var(--spacing-4, 1rem) var(--spacing-6, 1.5rem); + border-top: 1px solid hsl(var(--border, 214.3 31.8% 91.4%)); + background: hsl(var(--muted, 210 40% 96.1%)); +} + +.modal-footer .btn { + min-width: 80px; +} + +/* ============================================ + * Form Elements within Modal + * ============================================ */ + +.modal-body .form-row { + display: flex; + align-items: center; + gap: var(--spacing-3, 0.75rem); + margin-bottom: var(--spacing-3, 0.75rem); +} + +.modal-body .form-row label { + min-width: 100px; + font-size: var(--text-sm, 0.875rem); + color: hsl(var(--foreground, 222.2 84% 4.9%)); +} + +.modal-body .form-row input, +.modal-body .form-row select { + flex: 1; + padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem); + border: 1px solid hsl(var(--input, 214.3 31.8% 91.4%)); + border-radius: var(--radius, 0.5rem); + font-size: var(--text-sm, 0.875rem); + background: hsl(var(--background, 0 0% 100%)); + color: hsl(var(--foreground, 222.2 84% 4.9%)); +} + +.modal-body .form-row input:focus, +.modal-body .form-row select:focus { + outline: 2px solid hsl(var(--ring, 227 44% 52%)); + outline-offset: 1px; +} + +.modal-body .export-section { + margin-bottom: var(--spacing-4, 1rem); + padding-bottom: var(--spacing-4, 1rem); + border-bottom: 1px solid hsl(var(--border, 214.3 31.8% 91.4%)); +} + +.modal-body .export-section:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.modal-body .export-section label { + display: block; + font-weight: var(--font-medium, 500); + margin-bottom: var(--spacing-2, 0.5rem); + color: hsl(var(--foreground, 222.2 84% 4.9%)); +} + +.modal-body .checkbox-group label { + display: flex; + align-items: center; + gap: var(--spacing-2, 0.5rem); + margin-bottom: var(--spacing-2, 0.5rem); + font-weight: var(--font-normal, 400); + cursor: pointer; +} + +/* ============================================ + * Size Variants + * ============================================ */ + +.modal-content.small { + max-width: 400px; +} + +.modal-content.large { + max-width: 800px; +} + +.modal-content.full { + max-width: 95vw; + max-height: 95vh; +} + +/* ============================================ + * Dark Mode Support + * ============================================ */ + +.dark .modal-content, +[data-theme="dark"] .modal-content { + background: hsl(var(--background)); + box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.4), 0 8px 10px -6px rgb(0 0 0 / 0.3); +} + +.dark .modal-header, +.dark .modal-footer, +[data-theme="dark"] .modal-header, +[data-theme="dark"] .modal-footer { + background: hsl(var(--muted)); +} + +/* ============================================ + * Accessibility - Reduced Motion + * ============================================ */ + +@media (prefers-reduced-motion: reduce) { + .modal, + .modal-content { + transition: none; + } +} + +/* ============================================ + * Legacy Support - Hidden class + * ============================================ */ + +.modal.hidden { + display: none; +} +``` + +**Step 2: Commit modal.css** + +```bash +git add src/styles/modal.css +git commit -m "feat: add modal CSS with glassmorphism and animations" +``` + +--- + +## Task 3: Include Modal CSS in HTML + +**Files:** +- Modify: `src/index.html` (line ~18) + +**Step 1: Add modal.css link after tokens.css** + +Find this line in `src/index.html`: +```html + +``` + +Add after it: +```html + +``` + +**Step 2: Commit** + +```bash +git add src/index.html +git commit -m "feat: include modal.css in index.html" +``` + +--- + +## Task 4: Include ModalManager in HTML + +**Files:** +- Modify: `src/index.html` (near end of body, before renderer.js) + +**Step 1: Add script tag for ModalManager** + +Find the script tags near the end of body and add before renderer.js: +```html + +``` + +**Step 2: Commit** + +```bash +git add src/index.html +git commit -m "feat: include ModalManager script in index.html" +``` + +--- + +## Task 5: Convert Find Dialog + +**Files:** +- Modify: `src/index.html` (lines 160-174) +- Modify: `src/renderer.js` (find dialog handlers) + +**Step 1: Update HTML structure** + +Replace the find-dialog div (lines 160-174) with: + +```html + +