diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 95c5064c..e4532282 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -2042,6 +2042,111 @@ html.mobile-init .file-browser-panel { font-size: 0.7rem; } + .settings-item-has-description { + cursor: pointer; + } + + .settings-description-trigger { + min-width: 0; + touch-action: manipulation; + } + + .settings-description-trigger .settings-item-label, + .settings-item-label.settings-description-trigger { + text-decoration-line: underline; + text-decoration-style: dotted; + text-decoration-color: var(--text-muted); + text-underline-offset: 3px; + } + + .settings-description-trigger:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; + border-radius: 3px; + } + + .settings-description-layer { + position: absolute; + inset: 0; + z-index: 4; + display: flex; + align-items: flex-end; + justify-content: center; + padding: 0.75rem; + padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px)); + } + + .settings-description-layer[hidden] { + display: none; + } + + .settings-description-backdrop { + position: absolute; + inset: 0; + background: rgba(6, 9, 13, 0.72); + } + + .settings-description-panel { + position: relative; + width: 100%; + max-width: 420px; + max-height: min(60dvh, 420px); + overflow-y: auto; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.42); + color: var(--text); + } + + .settings-description-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + min-height: 48px; + padding: 0.35rem 0.35rem 0.35rem 0.85rem; + border-bottom: 1px solid var(--border); + } + + .settings-description-header h4 { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0; + } + + .settings-description-close { + flex: 0 0 44px; + width: 44px; + height: 44px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-dim); + font: inherit; + font-size: 1.35rem; + line-height: 1; + } + + .settings-description-close:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; + } + + .settings-description-text { + margin: 0; + padding: 0.85rem; + overflow-wrap: anywhere; + color: var(--text-dim); + font-size: 0.82rem; + line-height: 1.5; + letter-spacing: 0; + } + .settings-section-header { font-size: 0.6rem; padding: 0.35rem 0 0.2rem 0; diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 9e0c0d0c..f3adad7b 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -464,6 +464,7 @@ Object.assign(CodemanApp.prototype, { modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => { btn.onclick = () => this.switchSettingsTab(btn.dataset.tab); }); + this._prepareMobileSettingsDescriptions(); modal.classList.add('active'); // Activate focus trap @@ -486,7 +487,178 @@ Object.assign(CodemanApp.prototype, { if (tabName === 'settings-shortcuts') this.renderShortcutSettingsList?.(); }, + _ensureSettingsDescriptionLayer() { + let layer = document.getElementById('settingsDescriptionLayer'); + if (layer) return layer; + + const modal = document.getElementById('appSettingsModal'); + if (!modal) return null; + + layer = document.createElement('div'); + layer.id = 'settingsDescriptionLayer'; + layer.className = 'settings-description-layer'; + layer.hidden = true; + layer.setAttribute('aria-hidden', 'true'); + + const backdrop = document.createElement('div'); + backdrop.className = 'settings-description-backdrop'; + backdrop.setAttribute('aria-hidden', 'true'); + + const panel = document.createElement('div'); + panel.className = 'settings-description-panel'; + panel.setAttribute('role', 'dialog'); + panel.setAttribute('aria-modal', 'true'); + panel.setAttribute('aria-labelledby', 'settingsDescriptionTitle'); + panel.setAttribute('aria-describedby', 'settingsDescriptionText'); + + const header = document.createElement('div'); + header.className = 'settings-description-header'; + + const title = document.createElement('h4'); + title.id = 'settingsDescriptionTitle'; + + const close = document.createElement('button'); + close.id = 'settingsDescriptionClose'; + close.className = 'settings-description-close'; + close.type = 'button'; + close.setAttribute('aria-label', 'Close setting description'); + close.textContent = '\u00d7'; + + const text = document.createElement('p'); + text.id = 'settingsDescriptionText'; + text.className = 'settings-description-text'; + + header.append(title, close); + panel.append(header, text); + layer.append(backdrop, panel); + modal.appendChild(layer); + + backdrop.addEventListener('click', () => this.closeSettingsDescription()); + close.addEventListener('click', () => this.closeSettingsDescription()); + layer.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + this.closeSettingsDescription(); + }); + + return layer; + }, + + _prepareMobileSettingsDescriptions() { + const modal = document.getElementById('appSettingsModal'); + if (!modal) return; + + const isPhone = + typeof MobileDetection !== 'undefined' && + MobileDetection.getDeviceType?.() === 'mobile'; + const items = modal.querySelectorAll('.settings-item[title]'); + + for (const item of items) { + const trigger = item.querySelector('.settings-item-text') || item.querySelector('.settings-item-label'); + item.classList.toggle('settings-item-has-description', isPhone); + if (!trigger) continue; + + trigger.classList.toggle('settings-description-trigger', isPhone); + if (isPhone) { + trigger.setAttribute('role', 'button'); + trigger.setAttribute('tabindex', '0'); + trigger.setAttribute('aria-haspopup', 'dialog'); + trigger.setAttribute('aria-controls', 'settingsDescriptionLayer'); + } else { + trigger.removeAttribute('role'); + trigger.removeAttribute('tabindex'); + trigger.removeAttribute('aria-haspopup'); + trigger.removeAttribute('aria-controls'); + } + } + + if (!isPhone) { + this.closeSettingsDescription(); + return; + } + + this._ensureSettingsDescriptionLayer(); + if (this._settingsDescriptionHandlersReady) return; + this._settingsDescriptionHandlersReady = true; + + modal.addEventListener('click', (event) => { + const item = event.target.closest?.('.settings-item.settings-item-has-description'); + if (!item) return; + if (event.target.closest?.('input, select, textarea, button, a, label, .settings-item-actions')) return; + const trigger = item.querySelector('.settings-description-trigger'); + this.openSettingsDescription(item, trigger); + }); + + modal.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + const trigger = event.target.closest?.('.settings-description-trigger'); + if (!trigger) return; + const item = trigger.closest('.settings-item.settings-item-has-description'); + if (!item) return; + event.preventDefault(); + this.openSettingsDescription(item, trigger); + }); + }, + + openSettingsDescription(item, trigger) { + const description = item?.getAttribute?.('title')?.trim(); + if (!description) return; + + const layer = this._ensureSettingsDescriptionLayer(); + const modal = document.getElementById('appSettingsModal'); + const modalContent = modal?.querySelector('.modal-content'); + if (!layer || !modal || !modalContent) return; + + const label = item.querySelector('.settings-item-label')?.textContent?.trim() || 'Setting'; + document.getElementById('settingsDescriptionTitle').textContent = label; + document.getElementById('settingsDescriptionText').textContent = description; + + try { + trigger?.focus?.({ preventScroll: true }); + } catch { + trigger?.focus?.(); + } + + modalContent.inert = true; + modalContent.setAttribute('aria-hidden', 'true'); + layer.hidden = false; + layer.setAttribute('aria-hidden', 'false'); + + const parentTrap = this.activeFocusTrap; + this._settingsDescriptionParentTrap = parentTrap || null; + if (parentTrap) { + parentTrap.element.removeEventListener('keydown', parentTrap.boundHandleKeydown); + } + this._settingsDescriptionFocusTrap = new FocusTrap(layer); + this._settingsDescriptionFocusTrap.activate(); + }, + + closeSettingsDescription() { + const layer = document.getElementById('settingsDescriptionLayer'); + if (!layer || layer.hidden) return; + + const modalContent = document.getElementById('appSettingsModal')?.querySelector('.modal-content'); + if (modalContent) { + modalContent.inert = false; + modalContent.removeAttribute('aria-hidden'); + } + layer.hidden = true; + layer.setAttribute('aria-hidden', 'true'); + + const descriptionTrap = this._settingsDescriptionFocusTrap; + this._settingsDescriptionFocusTrap = null; + descriptionTrap?.deactivate(); + + const parentTrap = this._settingsDescriptionParentTrap; + this._settingsDescriptionParentTrap = null; + if (parentTrap && parentTrap === this.activeFocusTrap) { + parentTrap.element.addEventListener('keydown', parentTrap.boundHandleKeydown); + } + }, + closeAppSettings() { + this.closeSettingsDescription(); document.getElementById('appSettingsModal').classList.remove('active'); // Deactivate focus trap and restore focus diff --git a/test/mobile/settings.test.ts b/test/mobile/settings.test.ts index 5d6282b2..fc49c5a0 100644 --- a/test/mobile/settings.test.ts +++ b/test/mobile/settings.test.ts @@ -182,6 +182,50 @@ describe('Settings Modal', () => { } }); + it('opens the full option description without toggling the setting', async () => { + await openSettingsModal(); + + const item = page.locator('#appSettingsTerminalWheelLocal').locator('..').locator('..'); + const trigger = item.locator('.settings-item-text'); + const checkbox = page.locator('#appSettingsTerminalWheelLocal'); + const checkedBefore = await checkbox.isChecked(); + + await trigger.click(); + await assertVisible(page, '#settingsDescriptionLayer'); + + const description = await page.locator('#settingsDescriptionText').textContent(); + const accessibility = await trigger.evaluate((element) => ({ + role: element.getAttribute('role'), + tabIndex: element.getAttribute('tabindex'), + hasPopup: element.getAttribute('aria-haspopup'), + controls: element.getAttribute('aria-controls'), + })); + + expect(await page.locator('#settingsDescriptionTitle').textContent()).toBe('Wheel Scrolls Local History'); + expect(description).toContain("Scroll the terminal's own local scrollback"); + expect(await checkbox.isChecked()).toBe(checkedBefore); + expect(accessibility).toEqual({ + role: 'button', + tabIndex: '0', + hasPopup: 'dialog', + controls: 'settingsDescriptionLayer', + }); + + await page.locator('.settings-description-backdrop').click({ position: { x: 8, y: 8 } }); + await assertHidden(page, '#settingsDescriptionLayer'); + + await item.locator('.switch').click(); + expect(await checkbox.isChecked()).toBe(!checkedBefore); + await assertHidden(page, '#settingsDescriptionLayer'); + + await page.locator('.modal-tab-btn[data-tab="settings-notifications"]').click(); + await page.locator('#settings-notifications .settings-grid-3col .settings-item-label').first().click(); + await assertVisible(page, '#settingsDescriptionLayer'); + expect(await page.locator('#settingsDescriptionTitle').textContent()).toBe('Critical'); + expect(await page.locator('#settingsDescriptionText').textContent()).toBe('Errors, crashes, agent failures'); + await page.locator('#settingsDescriptionClose').click(); + }); + it('modal body adjusts height when keyboard visible', async () => { await openSettingsModal(); const modalBody = page.locator(SELECTORS.SETTINGS_MODAL_BODY).first();