Skip to content

Commit c3991d2

Browse files
committed
feat(ui): WebUI modernization PR7 — mobile layout + WCAG accessibility pass
Accessibility (desktop visuals unchanged): - Landmarks: <header> topbar, <nav aria-label="Sessions"> sidebar, <main> chat column; #messages is now role="log" aria-live="polite" aria-relevant="additions". - Visually-hidden #sr-status live region + announce() helper; announces connection state, system/error messages, approval shown/answered (with risk class), session loaded/renamed/deleted. - Dialog semantics: shortcuts + confirm overlays get role="dialog" aria-modal="true" aria-labelledby; focus moves in on open (data-autofocus), returns on close, Tab cycles within (openDialog/ closeDialog/focus trap in utils.js); Escape closes via the same helper. - Keyboard activation: role="button" tabindex="0" + Enter/Space for .tb-header, .thinking-toggle, .tb-result-more, .collapse-toggle, .es-hints spans, .chip-remove; aria-expanded tracked on disclosure toggles and the hamburger. - :focus-visible ring now covers [tabindex]/[role="button"] (the dead [onclick] selector was removed); shortcuts dialog documents @ completion navigation. Mobile (all inside @media (max-width: 767px), except 100dvh): - #app uses 100dvh with 100vh fallback; grid rows go auto so the topbar can grow to two rows — model picker + custom-model input wrap to a second row inside .top-controls, theme/cancel stay on row one. - Sidebar becomes an over-top drawer (top:0, z-index 30) so the taller topbar never clips it. - ≥40px touch targets via padding/min-size: hamburger, theme, cancel, new-session, session row actions, attach, scroll-bottom, approval action buttons. - Approval card full-width minus margins; toast wraps at 92vw instead of overflowing; completion popup verified full-width of input row.
1 parent a712b3d commit c3991d2

9 files changed

Lines changed: 178 additions & 39 deletions

File tree

cmd/odek/ui/index.html

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<body>
1212
<div id="app">
1313
<!-- Top Bar -->
14-
<div id="topbar">
14+
<header id="topbar">
1515
<button id="hamburger-btn" aria-label="Toggle sidebar">
1616
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
1717
</button>
@@ -34,28 +34,28 @@
3434
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
3535
</button>
3636
</div>
37-
</div>
37+
</header>
3838

3939
<!-- Sidebar -->
40-
<div id="sidebar">
40+
<nav id="sidebar" aria-label="Sessions">
4141
<div id="sidebar-header">
4242
<h3>Sessions</h3>
4343
<button class="new-session-btn" title="New session"></button>
4444
</div>
4545
<input id="sidebar-search" type="text" placeholder="Filter…" />
4646
<div id="session-list"></div>
47-
</div>
47+
</nav>
4848

4949
<div id="sidebar-overlay"></div>
5050

5151
<!-- Main Chat -->
52-
<div id="main">
52+
<main id="main">
5353
<div id="loading-skeleton">
5454
<div class="skeleton-bar"></div>
5555
<div class="skeleton-bar"></div>
5656
<div class="skeleton-bar"></div>
5757
</div>
58-
<div id="messages">
58+
<div id="messages" role="log" aria-live="polite" aria-relevant="additions">
5959
<button id="scroll-bottom-btn" aria-label="Scroll to bottom">
6060
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
6161
</button>
@@ -65,8 +65,8 @@ <h3>Sessions</h3>
6565
<div class="es-name">odek</div>
6666
<p class="es-sub">autonomous agent runtime</p>
6767
<div class="es-hints">
68-
<span>shortcuts /?/</span>
69-
<span>refresh sessions</span>
68+
<span role="button" tabindex="0">shortcuts /?/</span>
69+
<span role="button" tabindex="0">refresh sessions</span>
7070
</div>
7171
</div>
7272
</div>
@@ -91,18 +91,19 @@ <h3>Sessions</h3>
9191
</div>
9292
</div>
9393
</div>
94-
</div>
94+
</main>
9595
</div>
9696

9797
<!-- Keyboard Shortcuts -->
9898
<div id="shortcuts-overlay">
99-
<div id="shortcuts-dialog">
100-
<h3>Keyboard shortcuts</h3>
99+
<div id="shortcuts-dialog" role="dialog" aria-modal="true" aria-labelledby="shortcuts-title" tabindex="-1">
100+
<h3 id="shortcuts-title">Keyboard shortcuts</h3>
101101
<div class="shortcut-row"><span class="sc-label">Send message</span><span class="sc-key">Enter</span></div>
102102
<div class="shortcut-row"><span class="sc-label">New line</span><span class="sc-key">Shift+Enter</span></div>
103103
<div class="shortcut-row"><span class="sc-label">History up</span><span class="sc-key"></span></div>
104104
<div class="shortcut-row"><span class="sc-label">History down</span><span class="sc-key"></span></div>
105105
<div class="shortcut-row"><span class="sc-label">File/session reference</span><span class="sc-key">@</span></div>
106+
<div class="shortcut-row"><span class="sc-label">Completion navigation</span><span class="sc-key">↑↓ · Enter/Tab · Esc</span></div>
106107
<div class="shortcut-row"><span class="sc-label">Toggle shortcuts</span><span class="sc-key">?</span></div>
107108
<div class="shortcut-row"><span class="sc-label">Refresh sessions</span><span class="sc-key">⌘R</span></div>
108109
<div class="shortcut-row"><span class="sc-label">Toggle thinking</span><span class="sc-key">Alt+T</span></div>
@@ -114,17 +115,20 @@ <h3>Keyboard shortcuts</h3>
114115

115116
<!-- Confirm Dialog -->
116117
<div id="confirm-overlay">
117-
<div id="confirm-dialog">
118+
<div id="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
118119
<div class="confirm-icon"></div>
119-
<h3>Delete session?</h3>
120+
<h3 id="confirm-title">Delete session?</h3>
120121
<p id="confirm-msg">This action cannot be undone.</p>
121122
<div id="confirm-actions">
122-
<button class="cancel">Cancel</button>
123+
<button class="cancel" data-autofocus>Cancel</button>
123124
<button class="delete-confirm" id="confirm-delete-btn">Delete</button>
124125
</div>
125126
</div>
126127
</div>
127128

129+
<!-- Screen-reader-only live status region (announce() in js/utils.js) -->
130+
<div id="sr-status" class="sr-only" role="status" aria-live="polite"></div>
131+
128132
<div class="toast" id="toast"></div>
129133

130134
<script type="module" src="/app.js"></script>

cmd/odek/ui/js/approvals.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// inline decision card pinned at the bottom of the message stream.
33
import { S } from './state.js';
44
import { messagesEl } from './dom.js';
5-
import { forceScrollBottom } from './utils.js';
5+
import { forceScrollBottom, announce } from './utils.js';
66
import { hideEmptyState } from './render.js';
77

88
// Approval requests are queued and rendered one at a time as an inline
@@ -193,6 +193,7 @@ function renderApprovalCard(event) {
193193
messagesEl.appendChild(card);
194194
forceScrollBottom();
195195
card.focus({ preventScroll: true });
196+
announce('Approval required: ' + (event.risk || 'unknown') + ' risk operation');
196197
}
197198

198199
export function sendApproval(action) {
@@ -211,6 +212,8 @@ export function sendApproval(action) {
211212
S.approvalQueue.shift();
212213
removeActiveApprovalCard();
213214
showNextApproval();
215+
announce(action === 'trust' ? 'Risk class trusted for this session' :
216+
action === 'approve' ? 'Approved' : 'Denied');
214217
}
215218

216219
// Keyboard operation while an approval card is active. Ignored when the

cmd/odek/ui/js/input.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,13 @@ function renderFileChips() {
107107
const remove = document.createElement('span');
108108
remove.className = 'chip-remove';
109109
remove.textContent = '✕';
110+
remove.setAttribute('role', 'button');
111+
remove.setAttribute('tabindex', '0');
112+
remove.setAttribute('aria-label', 'Remove ' + f.name);
110113
remove.addEventListener('click', () => removeAttachedFile(i));
114+
remove.addEventListener('keydown', (e) => {
115+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); removeAttachedFile(i); }
116+
});
111117

112118
chip.append(icon, name, size, remove);
113119
fileChips.appendChild(chip);
@@ -139,10 +145,16 @@ function addErrorChip(name, reason) {
139145
const remove = document.createElement('span');
140146
remove.className = 'chip-remove';
141147
remove.textContent = '✕';
148+
remove.setAttribute('role', 'button');
149+
remove.setAttribute('tabindex', '0');
150+
remove.setAttribute('aria-label', 'Dismiss');
142151
chip.append(icon, label, remove);
143152
fileChips.appendChild(chip);
144153
const dismiss = () => chip.remove();
145154
remove.addEventListener('click', dismiss);
155+
remove.addEventListener('keydown', (e) => {
156+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); dismiss(); }
157+
});
146158
setTimeout(dismiss, 6000);
147159
}
148160

cmd/odek/ui/js/main.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { S, getSessionToken } from './state.js';
44
import { apiHeaders } from './net.js';
55
import { promptEl, skeletonEl, thinkBtn } from './dom.js';
6-
import { escapeHtml, escapeAttr, showToast, toggleShortcuts, hideCancel } from './utils.js';
6+
import { escapeHtml, escapeAttr, showToast, toggleShortcuts, hideCancel, closeDialog } from './utils.js';
77
import { addSystemMessage } from './render.js';
88
import { loadSessions } from './sessions.js';
99
import { connect } from './ws.js';
@@ -16,11 +16,18 @@ S.savedEmptyStateNode = document.getElementById('empty-state');
1616
S.savedScrollBtnNode = document.getElementById('scroll-bottom-btn');
1717

1818
// Empty-state hint actions (the saved node is re-appended on session
19-
// switches, so direct listeners persist).
19+
// switches, so direct listeners persist). The hints are role="button"
20+
// spans, so Enter/Space must activate them like a click.
21+
function activateOnKey(el, fn) {
22+
el.addEventListener('click', fn);
23+
el.addEventListener('keydown', (e) => {
24+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fn(); }
25+
});
26+
}
2027
if (S.savedEmptyStateNode) {
2128
const hints = S.savedEmptyStateNode.querySelectorAll('.es-hints span');
22-
if (hints[0]) hints[0].addEventListener('click', toggleShortcuts);
23-
if (hints[1]) hints[1].addEventListener('click', loadSessions);
29+
if (hints[0]) activateOnKey(hints[0], toggleShortcuts);
30+
if (hints[1]) activateOnKey(hints[1], loadSessions);
2431
}
2532

2633
// ── Theme Toggle ──
@@ -189,8 +196,7 @@ document.addEventListener('keydown', (e) => {
189196
// Escape closes overlays. Approval cards are deliberately NOT dismissed —
190197
// a decision must be made explicitly.
191198
if (e.key === 'Escape') {
192-
document.getElementById('shortcuts-overlay').classList.remove('active');
193-
document.getElementById('confirm-overlay').classList.remove('active');
199+
closeDialog(); // whichever overlay dialog is open (shortcuts / confirm)
194200
S.pendingDeleteId = null;
195201
}
196202
// Ctrl+R refreshes sessions

cmd/odek/ui/js/render.js

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { messagesEl, promptEl, sendBtn, emptyState } from './dom.js';
66
import {
77
escapeHtml, escapeAttr, truncateStr, copyTextToClipboard,
88
pruneMessages, scrollBottom, forceScrollBottom, stripAttachmentBodies,
9-
showCancel, hideCancel,
9+
showCancel, hideCancel, announce,
1010
} from './utils.js';
1111
import { markdownToHtml } from './markdown.js';
1212

@@ -88,7 +88,7 @@ export function streamThinking(content) {
8888
const block = document.createElement('div');
8989
block.className = 'thinking-block';
9090
block.innerHTML =
91-
'<div class="thinking-toggle">' +
91+
'<div class="thinking-toggle" role="button" tabindex="0" aria-expanded="false">' +
9292
'<span class="arrow">▶</span> reasoning' +
9393
'</div>' +
9494
'<div class="thinking-content">' + escapeHtml(content) + '</div>';
@@ -110,6 +110,7 @@ function toggleThinking(el) {
110110
if (content) {
111111
content.classList.toggle('open');
112112
arrow.classList.toggle('open');
113+
el.setAttribute('aria-expanded', content.classList.contains('open'));
113114
// Auto-open on first click
114115
if (content.classList.contains('open')) {
115116
scrollBottom();
@@ -248,6 +249,7 @@ export function addSystemMessage(content) {
248249
el.className = 'msg system';
249250
el.innerHTML = '<div class="bubble"><div class="content">' + escapeHtml(content) + '</div></div>';
250251
messagesEl.appendChild(el);
252+
announce(content);
251253
pruneMessages();
252254
scrollBottom();
253255
}
@@ -346,7 +348,7 @@ export function addToolCall(name, data) {
346348
const el = document.createElement('div');
347349
el.className = 'tool-block';
348350
el.innerHTML =
349-
'<div class="tb-header">' +
351+
'<div class="tb-header" role="button" tabindex="0" aria-expanded="false">' +
350352
'<span class="arrow">▶</span>' +
351353
' <span class="tb-emoji">' + emoji + '</span>' +
352354
' <span class="tb-name">' + escapeHtml(name) + '</span>' +
@@ -381,7 +383,7 @@ function appendToolResultContent(block, output) {
381383
if (truncated) {
382384
resultEl.innerHTML =
383385
escapeHtml(output.slice(0, MAX_RESULT)) +
384-
'<span class="tb-result-more" data-full="' +
386+
'<span class="tb-result-more" role="button" tabindex="0" data-full="' +
385387
escapeAttr(output) + '"> …show all (' + output.length + ' chars)</span>';
386388
} else {
387389
resultEl.textContent = output || '';
@@ -421,6 +423,7 @@ function toggleToolBody(header) {
421423
if (body) {
422424
body.classList.toggle('open');
423425
arrow.classList.toggle('open');
426+
header.setAttribute('aria-expanded', body.classList.contains('open'));
424427
}
425428
}
426429

@@ -645,7 +648,7 @@ function renderHistoricalThinking(content) {
645648
const block = document.createElement('div');
646649
block.className = 'thinking-block';
647650
block.innerHTML =
648-
'<div class="thinking-toggle">' +
651+
'<div class="thinking-toggle" role="button" tabindex="0" aria-expanded="false">' +
649652
'<span class="arrow">▶</span> reasoning' +
650653
'</div>' +
651654
'<div class="thinking-content">' + escapeHtml(content) + '</div>';
@@ -659,7 +662,7 @@ function renderHistoricalToolBlock(name, args, result) {
659662
const el = document.createElement('div');
660663
el.className = 'tool-block';
661664
el.innerHTML =
662-
'<div class="tb-header">' +
665+
'<div class="tb-header" role="button" tabindex="0" aria-expanded="false">' +
663666
'<span class="arrow">▶</span>' +
664667
' <span class="tb-emoji">' + toolEmoji(name) + '</span>' +
665668
' <span class="tb-name">' + escapeHtml(name) + '</span>' +
@@ -722,6 +725,8 @@ function checkCollapse(bubble) {
722725
bubble.classList.add('collapsible');
723726
const toggle = document.createElement('div');
724727
toggle.className = 'collapse-toggle';
728+
toggle.setAttribute('role', 'button');
729+
toggle.setAttribute('tabindex', '0');
725730
toggle.textContent = 'Show more ▼';
726731
bubble.appendChild(toggle);
727732
}
@@ -797,3 +802,13 @@ messagesEl.addEventListener('click', (e) => {
797802
const saDetails = t.closest('.sa-details');
798803
if (saDetails) { toggleSaDetails(saDetails); return; }
799804
});
805+
806+
// Keyboard activation for the role="button" elements above: Enter/Space
807+
// re-dispatches as a click so the same delegation handles it.
808+
messagesEl.addEventListener('keydown', (e) => {
809+
if (e.key !== 'Enter' && e.key !== ' ') return;
810+
const btn = e.target.closest('[role="button"]');
811+
if (!btn || !messagesEl.contains(btn)) return;
812+
e.preventDefault();
813+
btn.click();
814+
});

cmd/odek/ui/js/sessions.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { S, getSessionToken, setSessionToken, clearSessionToken, ensureSessionToken } from './state.js';
44
import { apiHeaders } from './net.js';
55
import { messagesEl, promptEl, sendBtn, sessionListEl, sidebarSearch, sidebarOverlay } from './dom.js';
6-
import { escapeHtml, escapeAttr, relativeTime, showToast, forceScrollBottom, hideCancel } from './utils.js';
6+
import { escapeHtml, escapeAttr, relativeTime, showToast, forceScrollBottom, hideCancel, announce, openDialog, closeDialog, isDialogOpen } from './utils.js';
77
import { resetTurnState, hideLoading, renderSessionHistory } from './render.js';
88
import { removeActiveApprovalCard } from './approvals.js';
99

@@ -131,6 +131,7 @@ export async function loadAndRenderSession(sid) {
131131

132132
forceScrollBottom();
133133
showToast('Session loaded');
134+
announce('Session loaded');
134135
} catch (err) {
135136
showToast('Error loading session');
136137
}
@@ -189,21 +190,24 @@ async function doRenameSession(sid, newName) {
189190
if (!resp.ok) throw new Error('rename failed');
190191
loadSessions();
191192
showToast('Session renamed');
193+
announce('Session renamed');
192194
})
193195
.catch(() => showToast('Failed to rename session'));
194196
}
195197

196198
// ── Confirm Dialog ──
197199
export function hideConfirmDialog() {
198-
document.getElementById('confirm-overlay').classList.remove('active');
200+
const overlay = document.getElementById('confirm-overlay');
201+
if (isDialogOpen(overlay)) closeDialog();
202+
else overlay.classList.remove('active');
199203
S.pendingDeleteId = null;
200204
}
201205

202206
export async function executeDeleteSession() {
203207
if (!S.pendingDeleteId) return;
204208
const sid = S.pendingDeleteId;
205209
S.pendingDeleteId = null;
206-
document.getElementById('confirm-overlay').classList.remove('active');
210+
hideConfirmDialog();
207211

208212
const token = await ensureSessionToken(sid);
209213

@@ -215,6 +219,7 @@ export async function executeDeleteSession() {
215219
clearSessionToken(sid);
216220
if (S.sessionId === sid) newSession();
217221
loadSessions();
222+
announce('Session deleted');
218223
})
219224
.catch(() => showToast('Failed to delete session'));
220225
}
@@ -225,6 +230,8 @@ export function toggleSidebar() {
225230
if (!sidebar) return;
226231
sidebar.classList.toggle('active');
227232
if (sidebarOverlay) sidebarOverlay.classList.toggle('active');
233+
const hamburger = document.getElementById('hamburger-btn');
234+
if (hamburger) hamburger.setAttribute('aria-expanded', sidebar.classList.contains('active'));
228235
}
229236

230237
// ── Session list click delegation ──
@@ -239,7 +246,7 @@ sessionListEl.addEventListener('click', (e) => {
239246
e.stopPropagation();
240247
S.pendingDeleteId = sid;
241248
document.getElementById('confirm-msg').textContent = 'Delete session ' + sid.slice(0, 8) + '...?';
242-
document.getElementById('confirm-overlay').classList.add('active');
249+
openDialog(document.getElementById('confirm-overlay'));
243250
return;
244251
}
245252

0 commit comments

Comments
 (0)