Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/web/public/mobile.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
172 changes: 172 additions & 0 deletions src/web/public/settings-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
44 changes: 44 additions & 0 deletions test/mobile/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down