Skip to content
Open
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
65 changes: 59 additions & 6 deletions src/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1416,7 +1416,8 @@ function renderCard(s, idx) {
return '<span class="tag-pill tag-' + escHtml(t) + '" onclick="event.stopPropagation();removeTag(\'' + s.id + '\',\'' + t + '\')">' + escHtml(t) + ' &times;</span>';
}).join('');

var html = '<div class="' + classes + '" data-id="' + s.id + '" onclick="onCardClick(\'' + s.id + '\', event)">';
var cardLabel = escHtml(projName + ': ' + getSessionDisplayName(s).slice(0, 80) + ' — ' + toolLabel + ', ' + timeAgo(s.last_ts));
var html = '<div class="' + classes + '" data-id="' + s.id + '" tabindex="0" aria-label="' + cardLabel + '" onclick="onCardClick(\'' + s.id + '\', event)" onkeydown="onCardKeydown(event)">';
html += '<div class="card-top">';
html += '<input type="checkbox" class="card-checkbox" style="' + checkboxStyle + '" ' + (isSelected ? 'checked' : '') + ' onclick="toggleSelect(\'' + s.id + '\', event)">';
html += renderToolBadges(s.tool, s);
Expand Down Expand Up @@ -1519,7 +1520,8 @@ function renderListCard(s, idx) {
if (isSelected) classes += ' selected';
if (isFocused) classes += ' focused';

var html = '<div class="' + classes + '" data-id="' + s.id + '" onclick="onCardClick(\'' + s.id + '\', event)">';
var listLabel = escHtml(projName + ': ' + getSessionDisplayName(s).slice(0, 80) + ' — ' + getToolLabel(s.tool, true) + ', ' + timeAgo(s.last_ts));
var html = '<div class="' + classes + '" data-id="' + s.id + '" tabindex="0" aria-label="' + listLabel + '" onclick="onCardClick(\'' + s.id + '\', event)" onkeydown="onCardKeydown(event)">';
html += renderToolBadges(s.tool, s);
if (showBadges && s.mcp_servers && s.mcp_servers.length > 0) {
s.mcp_servers.forEach(function(m) {
Expand Down Expand Up @@ -1652,6 +1654,21 @@ function onCardClick(id, event) {
}
}

// Session cards (.card / .list-row / .qa-item) are plain divs with nested
// interactive controls (checkbox, star, tag, launch buttons) — not real
// <button>s, so Enter/Space don't activate them for free like a native
// button would. This makes the card itself keyboard-activatable while
// leaving its nested controls' own native key handling alone: only react
// when the key event's target IS the card (not a bubbled event from a
// descendant button/checkbox, which already handles its own Enter/Space).
function onCardKeydown(e) {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.currentTarget.click(); // reuses the card's own onclick handler
}
}

// ── Rendering: Main ────────────────────────────────────────────

function render() {
Expand Down Expand Up @@ -1909,7 +1926,8 @@ function renderQACard(s, idx) {
var costStr = cost > 0 ? '~$' + cost.toFixed(2) : '';
var classes = 'qa-item' + (selectedIds.has(s.id) ? ' selected' : '');

var html = '<div class="' + classes + '" data-id="' + s.id + '" onclick="onCardClick(\'' + s.id + '\', event)">';
var qaLabel = escHtml(getSessionDisplayName(s).slice(0, 100) + ' — ' + toolLabel + ', ' + timeAgo(s.last_ts));
var html = '<div class="' + classes + '" data-id="' + s.id + '" tabindex="0" aria-label="' + qaLabel + '" onclick="onCardClick(\'' + s.id + '\', event)" onkeydown="onCardKeydown(event)">';
html += renderToolBadges(s.tool, s);
html += '<span class="qa-question">' + escHtml(getSessionDisplayName(s).slice(0, 160)) + '</span>';
html += '<span class="qa-meta">';
Expand Down Expand Up @@ -4027,9 +4045,10 @@ function _installModalFocusTrap(overlay) {
_modalTrapFn = function(e) {
if (e.key === 'Escape') {
e.stopPropagation();
// Both modals route close through the same callback chain.
// All three modals route close through the same callback chain.
if (overlay.id === 'projectsSettingsOverlay') closeProjectsSettings();
else if (overlay.id === 'addProjectOverlay' && typeof closeAddProject === 'function') closeAddProject();
else if (overlay.id === 'detailPanel' && typeof closeDetail === 'function') closeDetail();
return;
}
if (e.key !== 'Tab') return;
Expand All @@ -4050,7 +4069,12 @@ function _uninstallModalFocusTrap() {
document.removeEventListener('keydown', _modalTrapFn, true);
_modalTrapFn = null;
}
if (_modalFocusReturn && _modalFocusReturn.focus) {
// A background poll/re-render (e.g. the 5s active-sessions refresh) can
// rebuild the session grid while the modal was open, detaching the node we
// captured. Focusing a detached element is a silent no-op that strands
// focus wherever it happened to be (often the modal's own now-hidden close
// button) — check it's still on the page first.
if (_modalFocusReturn && _modalFocusReturn.focus && document.body.contains(_modalFocusReturn)) {
try { _modalFocusReturn.focus(); } catch (e) {}
}
_modalFocusReturn = null;
Expand Down Expand Up @@ -4313,7 +4337,14 @@ function closeAddProject() {
function addProjectSwitchTab(tab) {
['local', 'owned', 'contributing'].forEach(function(t) {
var btn = document.querySelector('.ap-tab[data-tab="' + t + '"]');
if (btn) btn.classList.toggle('active', t === tab);
if (!btn) return;
var isActive = t === tab;
btn.classList.toggle('active', isActive);
// WAI-ARIA tab pattern: aria-selected + roving tabindex must track the
// active tab, not just the visual .active class — otherwise a screen
// reader keeps announcing the first tab as selected forever.
btn.setAttribute('aria-selected', isActive ? 'true' : 'false');
btn.setAttribute('tabindex', isActive ? '0' : '-1');
});
document.getElementById('apPaneLocal').style.display = tab === 'local' ? '' : 'none';
document.getElementById('apPaneOwned').style.display = tab === 'owned' ? '' : 'none';
Expand All @@ -4328,6 +4359,28 @@ function addProjectSwitchTab(tab) {
}
}

// Left/Right cycle between the three Add Project tabs, Home/End jump to ends
// — same WAI-ARIA tablist behavior as the Projects/History subtab strip
// (onProjectsSubtabKey). Wired via the tablist's onkeydown in index.html.
var AP_TAB_ORDER = ['local', 'owned', 'contributing'];
function onAddProjectTabKey(e) {
var k = e.key;
if (k !== 'ArrowLeft' && k !== 'ArrowRight' && k !== 'Home' && k !== 'End') return;
e.preventDefault();
var current = document.querySelector('.ap-tab.active');
var idx = current ? AP_TAB_ORDER.indexOf(current.getAttribute('data-tab')) : 0;
if (idx < 0) idx = 0;
var next;
if (k === 'Home') next = 0;
else if (k === 'End') next = AP_TAB_ORDER.length - 1;
else if (k === 'ArrowRight') next = (idx + 1) % AP_TAB_ORDER.length;
else next = (idx - 1 + AP_TAB_ORDER.length) % AP_TAB_ORDER.length;
var nextTab = AP_TAB_ORDER[next];
addProjectSwitchTab(nextTab);
var btn = document.querySelector('.ap-tab[data-tab="' + nextTab + '"]');
if (btn) btn.focus();
}

async function submitAddLocalProject() {
var input = document.getElementById('apLocalPath');
var err = document.getElementById('apLocalError');
Expand Down
44 changes: 35 additions & 9 deletions src/frontend/calendar.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function toggleCalendar() {
var btn = document.getElementById('dateBtn');
if (!popup || !btn) return;
if (popup.classList.contains('open')) {
popup.classList.remove('open');
closeCalendar();
return;
}
renderCalendar();
Expand All @@ -26,16 +26,40 @@ function toggleCalendar() {
popup.style.left = left + 'px';
popup.style.top = (rect.bottom + 4) + 'px';
popup.classList.add('open');
btn.setAttribute('aria-expanded', 'true');
// Move focus into the dialog (it's a real dialog now — role="dialog" +
// aria-modal) so keyboard/screen-reader users land inside it, not on
// whatever was behind the button.
popup.focus();
setTimeout(function() {
document.addEventListener('click', closeCalendarOutside, { once: true });
}, 0);
}

// Closes the popup and — unless the trigger button itself is what's about to
// receive focus anyway (a plain outside click on unrelated UI) — returns
// focus to the date button, so a keyboard user isn't dropped onto <body>.
function closeCalendar(returnFocus) {
var popup = document.getElementById('calendarPopup');
var btn = document.getElementById('dateBtn');
if (!popup) return;
popup.classList.remove('open');
if (btn) btn.setAttribute('aria-expanded', 'false');
if (returnFocus !== false && btn) btn.focus();
}

function onCalendarPopupKeydown(e) {
if (e.key === 'Escape') {
e.stopPropagation();
closeCalendar();
}
}

function closeCalendarOutside(e) {
var popup = document.getElementById('calendarPopup');
var btn = document.getElementById('dateBtn');
if (popup && !popup.contains(e.target) && btn && !btn.contains(e.target)) {
popup.classList.remove('open');
closeCalendar(false); // the click already moved focus elsewhere — don't fight it
} else if (popup && popup.classList.contains('open')) {
document.addEventListener('click', closeCalendarOutside, { once: true });
}
Expand Down Expand Up @@ -64,28 +88,31 @@ function renderCalendar() {

var prevLastDay = new Date(calYear, calMonth, 0).getDate();
for (var i = startWeekday - 1; i >= 0; i--) {
html += '<div class="cal-day other-month">' + (prevLastDay - i) + '</div>';
html += '<div class="cal-day other-month" aria-hidden="true">' + (prevLastDay - i) + '</div>';
}

for (var d = 1; d <= daysInMonth; d++) {
var dateStr = calYear + '-' + String(calMonth+1).padStart(2,'0') + '-' + String(d).padStart(2,'0');
var cls = 'cal-day';
var selected = false;
if (dateStr === todayStr) cls += ' today';
if (calStart && calEnd) {
if (dateStr === calStart) cls += ' range-start';
if (dateStr === calEnd) cls += ' range-end';
if (dateStr === calStart) { cls += ' range-start'; selected = true; }
if (dateStr === calEnd) { cls += ' range-end'; selected = true; }
if (dateStr > calStart && dateStr < calEnd) cls += ' in-range';
if (calStart === calEnd && dateStr === calStart) cls += ' range-start range-end';
} else if (calStart && dateStr === calStart) {
cls += ' range-start range-end';
selected = true;
}
html += '<div class="' + cls + '" onclick="event.stopPropagation();calPickDay(\'' + dateStr + '\')">' + d + '</div>';
html += '<button type="button" class="' + cls + '" aria-pressed="' + selected + '" ' +
'onclick="event.stopPropagation();calPickDay(\'' + dateStr + '\')">' + d + '</button>';
}

var totalCells = startWeekday + daysInMonth;
var remaining = (7 - (totalCells % 7)) % 7;
for (var n = 1; n <= remaining; n++) {
html += '<div class="cal-day other-month">' + n + '</div>';
html += '<div class="cal-day other-month" aria-hidden="true">' + n + '</div>';
}
html += '</div>';

Expand Down Expand Up @@ -147,8 +174,7 @@ function calPreset(days) {
}
renderCalendar();
onDateFilter();
var popup = document.getElementById('calendarPopup');
if (popup) popup.classList.remove('open');
closeCalendar(false); // the preset button stays visually in place; no need to yank focus
}

function updateDateBtn() {
Expand Down
8 changes: 7 additions & 1 deletion src/frontend/detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ async function openDetail(s) {

panel.classList.add('open');
overlay.classList.add('open');
// Detail is a real dialog (role="dialog" aria-modal, index.html) — trap Tab
// inside it and return focus to whatever opened it on close, same helper
// the Add Project / Projects Settings modals use.
if (typeof _installModalFocusTrap === 'function') _installModalFocusTrap(panel);

// Load messages
if (s.has_detail) {
Expand Down Expand Up @@ -194,8 +198,10 @@ async function openDetail(s) {
function closeDetail() {
var panel = document.getElementById('detailPanel');
var overlay = document.getElementById('overlay');
if (panel) panel.classList.remove('open');
if (!panel || !panel.classList.contains('open')) return; // already closed — don't steal focus back a 2nd time
panel.classList.remove('open');
if (overlay) overlay.classList.remove('open');
if (typeof _uninstallModalFocusTrap === 'function') _uninstallModalFocusTrap();
}

var structuredMessageRenderers = {
Expand Down
16 changes: 8 additions & 8 deletions src/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -249,11 +249,11 @@
<option value="deploy">deploy</option>
<option value="review">review</option>
</select>
<button class="toolbar-btn" id="dateBtn" onclick="toggleCalendar()" title="Date filter">
<button class="toolbar-btn" id="dateBtn" onclick="toggleCalendar()" title="Date filter" aria-haspopup="dialog" aria-expanded="false" aria-controls="calendarPopup">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<span id="dateBtnLabel">All time</span>
</button>
<div class="calendar-popup" id="calendarPopup"></div>
<div class="calendar-popup" id="calendarPopup" role="dialog" aria-modal="true" aria-label="Choose date range" tabindex="-1" onkeydown="onCalendarPopupKeydown(event)"></div>
<button class="toolbar-btn" onclick="toggleGroup()" id="groupBtn">Group</button>
<button class="toolbar-btn" onclick="toggleLayout()" id="layoutBtn" title="Grid / List">
<svg id="layoutIcon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Expand All @@ -267,11 +267,11 @@
</div>

<div class="overlay" id="overlay" onclick="closeDetail()"></div>
<div class="detail-panel" id="detailPanel">
<div class="detail-panel" id="detailPanel" role="dialog" aria-modal="true" aria-labelledby="detailTitle">
<div class="detail-resize-handle" id="detailResizeHandle"></div>
<div class="detail-header">
<strong id="detailTitle">Session Detail</strong>
<button class="detail-close" onclick="closeDetail()">&times;</button>
<button class="detail-close" onclick="closeDetail()" aria-label="Close session detail">&times;</button>
</div>
<div class="detail-body" id="detailBody"></div>
</div>
Expand All @@ -292,10 +292,10 @@ <h3 id="confirmTitle">Delete Session?</h3>
<div class="confirm-overlay" id="addProjectOverlay" role="dialog" aria-modal="true" aria-labelledby="apTitle" aria-hidden="true">
<div class="confirm-box add-project-box">
<h3 id="apTitle">Add project</h3>
<div class="ap-tabs" role="tablist" aria-label="Add project source">
<button class="ap-tab active" data-tab="local" role="tab" aria-selected="true" tabindex="0" onclick="addProjectSwitchTab('local')">Local path</button>
<button class="ap-tab" data-tab="owned" role="tab" aria-selected="false" tabindex="-1" onclick="addProjectSwitchTab('owned')">My GitHub repos</button>
<button class="ap-tab" data-tab="contributing" role="tab" aria-selected="false" tabindex="-1" onclick="addProjectSwitchTab('contributing')">Contributing</button>
<div class="ap-tabs" role="tablist" aria-label="Add project source" onkeydown="onAddProjectTabKey(event)">
<button class="ap-tab active" data-tab="local" role="tab" aria-selected="true" aria-controls="apPaneLocal" tabindex="0" onclick="addProjectSwitchTab('local')">Local path</button>
<button class="ap-tab" data-tab="owned" role="tab" aria-selected="false" aria-controls="apPaneOwned" tabindex="-1" onclick="addProjectSwitchTab('owned')">My GitHub repos</button>
<button class="ap-tab" data-tab="contributing" role="tab" aria-selected="false" aria-controls="apPaneContrib" tabindex="-1" onclick="addProjectSwitchTab('contributing')">Contributing</button>
</div>
<div class="ap-body">
<div class="ap-pane" id="apPaneLocal" role="tabpanel">
Expand Down
12 changes: 10 additions & 2 deletions src/frontend/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,7 @@ body[data-view="overview"] .toolbar { display: none; }
box-shadow: 0 0 0 1px var(--accent-blue);
}

.card.focused {
.card.focused, .card:focus-visible {
outline: 2px solid var(--accent-blue);
outline-offset: 2px;
}
Expand Down Expand Up @@ -949,6 +949,7 @@ body[data-view="overview"] .toolbar { display: none; }
z-index: 50;
}
.calendar-popup.open { display: block; }
.calendar-popup:focus { outline: none; } /* dialog container itself isn't the visible target — the first day/nav control is */

.cal-header {
display: flex;
Expand Down Expand Up @@ -987,8 +988,10 @@ body[data-view="overview"] .toolbar { display: none; }
cursor: pointer;
color: var(--text-secondary);
transition: all 0.1s;
background: none; border: none; font-family: inherit; width: 100%;
}
.cal-day:hover { background: rgba(255,255,255,0.08); color: var(--text-primary); }
.cal-day:focus-visible { outline: 2px solid var(--accent-blue); outline-offset: -1px; }
.cal-day.other-month { color: var(--text-muted); opacity: 0.3; }
.cal-day.today { font-weight: 700; color: var(--accent-blue); }
.cal-day.in-range { background: rgba(96,165,250,0.1); }
Expand Down Expand Up @@ -2225,6 +2228,7 @@ body[data-view="overview"] .toolbar { display: none; }
.qa-item:last-child { border-bottom: none; }
.qa-item:hover { background: var(--bg-card-hover); }
.qa-item.selected { background: rgba(96, 165, 250, 0.08); }
.qa-item:focus-visible { outline: 2px solid var(--accent-blue); outline-offset: -2px; }

.qa-question {
flex: 1;
Expand Down Expand Up @@ -3177,8 +3181,10 @@ body[data-view="overview"] .toolbar { display: none; }
display: flex; flex-direction: column; gap: 6px; text-align: left;
padding: 14px; background: var(--bg-card); border: 1px solid var(--border);
border-radius: 12px; cursor: pointer; transition: border-color 0.15s;
font: inherit; width: 100%;
}
.ov-card:hover { border-color: var(--accent-blue); }
.ov-card:focus-visible { outline: 2px solid var(--accent-blue); outline-offset: 2px; }
.ov-card.limit { border-color: var(--accent-red); }
.ov-card-top { display: flex; align-items: center; gap: 8px; }
.ov-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); }
Expand Down Expand Up @@ -3297,6 +3303,8 @@ body[data-view="overview"] .toolbar { display: none; }
.ws-resizer-h::before { left: 0; right: 0; top: 50%; height: 2px; transform: translateY(-50%); }
.ws-resizer:hover::before, .ws-resizer.dragging::before { background: var(--accent-blue); }
.ws-resizer.dragging::before { box-shadow: 0 0 8px color-mix(in srgb, var(--accent-blue) 60%, transparent); }
.ws-resizer:focus-visible::before { background: var(--accent-blue); box-shadow: 0 0 8px color-mix(in srgb, var(--accent-blue) 60%, transparent); }
.ws-resizer:focus-visible { outline: none; } /* the ::before line itself is the focus indicator */

.ws-pane {
display: flex;
Expand Down Expand Up @@ -4417,7 +4425,7 @@ body[data-view="overview"] .toolbar { display: none; }
border-color: var(--accent-blue);
}

.list-row.focused {
.list-row.focused, .list-row:focus-visible {
outline: 2px solid var(--accent-blue);
outline-offset: -2px;
}
Expand Down
Loading
Loading