Skip to content

Commit 734c225

Browse files
sschabackerclaude
andcommitted
Split events/visits into tabs, relocate Save project file, soften re-locate
Three things found by hands-on testing tonight: 1. "Detected events" and "Merged visits" were two full stacked tables - a real trial can produce 1000+ raw event rows before merging, making the merged table (usually much shorter and more useful) something you had to scroll past everything else to reach. Split into a real ARIA tablist, same mechanism already proven for Video/Path/Heat map/Raster (not a new pattern) - each renders into its own panel, switch by click or arrow keys. 2. "Save project file" was buried at the tail end of the Mouse Tracking section, easy to miss even for the person who built it. Moved into the Export section next to the CSV/XLSX downloads, with a one-line distinction ("not spreadsheet data - a reloadable copy of this trial's own work") so it doesn't read as a third export format. 3. Clicking a roster video that was named by a folder pick but never actually opened fired a native OS file-picker with zero warning - "it's in your list, but not really, go find it yourself." Now shows an inline prompt with its own "Locate this file" button first, so the system dialog is something you deliberately triggered, not a surprise. Also (from an earlier fix this session, same root cause): the same type-poke/type-entry/type-thigmotaxis class-name collision that made Detected events unreadable also affected Merged visits - same fix applied there too. 75/75 tests pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 62572c6 commit 734c225

2 files changed

Lines changed: 145 additions & 31 deletions

File tree

‎app/index.html‎

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,27 @@
192192
}
193193
#rosterReplaceBtn { border: 1px solid var(--border); background: white; }
194194
#rosterAddBtn { border: 1px solid var(--accent); background: var(--accent); color: white; }
195+
/* An inline lead-up before the native file picker, not a silent auto-
196+
trigger on the roster click itself - a system dialog appearing with
197+
no warning read as a bait-and-switch ("it's in your list, but not
198+
really, go find it yourself"), found by her hands-on testing. */
199+
#rosterLocatePrompt {
200+
border: 1px solid var(--accent);
201+
background: #eef4fb;
202+
border-radius: 8px;
203+
padding: 0.85rem 1rem;
204+
margin: 0.75rem 0;
205+
}
206+
#rosterLocatePrompt p { margin: 0 0 0.6rem; font-size: 0.9rem; }
207+
#rosterLocatePrompt button {
208+
font-size: 0.85rem;
209+
padding: 0.35rem 0.75rem;
210+
border-radius: 6px;
211+
cursor: pointer;
212+
margin-right: 0.5rem;
213+
}
214+
#rosterLocateConfirmBtn { border: 1px solid var(--accent); background: var(--accent); color: white; }
215+
#rosterLocateCancelBtn { border: 1px solid var(--border); background: white; }
195216
#rosterCancelBtn { border: 1px solid var(--border); background: white; }
196217
#dropZone label {
197218
display: inline-block;
@@ -611,7 +632,10 @@
611632
#visitsTable { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
612633
#visitsTable th, #visitsTable td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
613634
#visitsTable th { color: var(--accent); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.02em; }
635+
/* Same class-name collision as #eventsTable above, same fix. */
614636
#visitsTable tr.type-entry { background: #eaf6ee; }
637+
#visitsTable tr.type-poke { background: white; }
638+
#visitsTable tr.type-thigmotaxis { background: white; }
615639
#visitsTable tr.has-override { box-shadow: inset 3px 0 0 var(--cellflag); }
616640
#visitsTable .audited-badge {
617641
display: inline-block;
@@ -696,6 +720,13 @@ <h2>Videos</h2>
696720
<p class="placeholder-note" id="rosterEmptyNote">No videos yet - load one with Browse, drag/drop, or Open folder.</p>
697721
<ul class="roster-list" id="rosterList" hidden></ul>
698722
<input type="file" id="rosterLocateInput" accept="video/mp4,.mp4" hidden>
723+
<div id="rosterLocatePrompt" hidden>
724+
<p id="rosterLocatePromptText"></p>
725+
<div>
726+
<button type="button" id="rosterLocateConfirmBtn">Locate this file</button>
727+
<button type="button" id="rosterLocateCancelBtn">Cancel</button>
728+
</div>
729+
</div>
699730
<button type="button" id="rosterClearBtn" hidden>Clear all videos</button>
700731
<div id="rosterClearConfirm" hidden>
701732
<p id="rosterClearConfirmText"></p>
@@ -885,10 +916,6 @@ <h2>Videos</h2>
885916
<canvas id="trackFailureStrip" width="640" height="24" aria-label="Tracking failure timeline: green is tracked, red is failed, across the whole video"></canvas>
886917
</div>
887918
<p class="subtitle" id="trackScrubHint" hidden>Scrub the frame viewer above - green crosshair = tracked, red X = failed frame.</p>
888-
889-
<p class="subtitle" id="saveProjectFileHint" hidden>Save a portable <code>_AUDITED.bmp.json</code> file with everything except the video itself (which stays wherever it already lives) - open it later, on any computer, to resume without re-tracking. Save it in the same folder as the video, next to it - the name makes it obvious at a glance, to you or anyone else, that this trial's already been done.</p>
890-
<button id="saveProjectFileBtn" type="button" hidden>Save project file</button>
891-
<div id="saveProjectFileStatus" role="status" aria-live="polite"></div>
892919
</div>
893920
</details>
894921

@@ -954,6 +981,11 @@ <h3>Export</h3>
954981
</details>
955982
<button id="exportDetailBtn" type="button" disabled>Detect events first</button>
956983
<div id="exportDetailStatus" role="status" aria-live="polite"></div>
984+
985+
<p class="subtitle">Not spreadsheet data - a reloadable copy of this trial's own work, to resume later without re-tracking.</p>
986+
<p class="subtitle" id="saveProjectFileHint" hidden>Save a portable <code>_AUDITED.bmp.json</code> file with everything except the video itself (which stays wherever it already lives) - open it later, on any computer, to resume without re-tracking. Save it in the same folder as the video, next to it - the name makes it obvious at a glance, to you or anyone else, that this trial's already been done.</p>
987+
<button id="saveProjectFileBtn" type="button" hidden>Save project file</button>
988+
<div id="saveProjectFileStatus" role="status" aria-live="polite"></div>
957989
</div>
958990
</div>
959991
</div>
@@ -1155,29 +1187,36 @@ <h3>Confirm &amp; adjust</h3>
11551187
<button id="eventsBtn" type="button" disabled>Track mouse first</button>
11561188
<div id="eventsStatus" role="status" aria-live="polite"></div>
11571189

1158-
<h4>Detected events</h4>
1159-
<div id="eventsTableWrap" hidden>
1160-
<table id="eventsTable">
1161-
<thead>
1162-
<tr><th>Hole</th><th>Frames</th><th>Type</th><th>Confidence</th><th>Avg speed</th><th>Human review</th><th></th></tr>
1163-
</thead>
1164-
<tbody id="eventsTableBody"></tbody>
1165-
</table>
1190+
<div class="viz-tabs" role="tablist" aria-label="Events view" id="eventsSubTabs" hidden>
1191+
<button type="button" role="tab" id="tab-detected-events" aria-selected="true" aria-controls="panel-detected-events">Detected events</button>
1192+
<button type="button" role="tab" id="tab-merged-visits" aria-selected="false" aria-controls="panel-merged-visits" tabindex="-1">Merged visits</button>
11661193
</div>
11671194

1168-
<h4>Merged visits</h4>
1169-
<p class="subtitle">Two visits to the same hole count as one unless the mouse fully disengaged in between.</p>
1170-
<details class="info-toggle">
1171-
<summary>What does this mean?</summary>
1172-
<p class="subtitle">Two events at the same hole are merged into one visit when the raw center-density signal between them never drops close to baseline (reengage fraction x min center density) - a brief dip under the qualifying threshold doesn't end a visit, but a real return to baseline does. See DECISIONS.md 2026-09-04. Feeds the search-strategy classifier's visit sequence and the errors/perseverative-errors measure - not shown elsewhere yet.</p>
1173-
</details>
1174-
<div id="visitsTableWrap" hidden>
1175-
<table id="visitsTable">
1176-
<thead>
1177-
<tr><th>Hole</th><th>Frames</th><th>Events merged</th><th>Type</th><th></th></tr>
1178-
</thead>
1179-
<tbody id="visitsTableBody"></tbody>
1180-
</table>
1195+
<div role="tabpanel" id="panel-detected-events" aria-labelledby="tab-detected-events">
1196+
<div id="eventsTableWrap" hidden>
1197+
<table id="eventsTable">
1198+
<thead>
1199+
<tr><th>Hole</th><th>Frames</th><th>Type</th><th>Confidence</th><th>Avg speed</th><th>Human review</th><th></th></tr>
1200+
</thead>
1201+
<tbody id="eventsTableBody"></tbody>
1202+
</table>
1203+
</div>
1204+
</div>
1205+
1206+
<div role="tabpanel" id="panel-merged-visits" aria-labelledby="tab-merged-visits" hidden>
1207+
<p class="subtitle">Two visits to the same hole count as one unless the mouse fully disengaged in between.</p>
1208+
<details class="info-toggle">
1209+
<summary>What does this mean?</summary>
1210+
<p class="subtitle">Two events at the same hole are merged into one visit when the raw center-density signal between them never drops close to baseline (reengage fraction x min center density) - a brief dip under the qualifying threshold doesn't end a visit, but a real return to baseline does. See DECISIONS.md 2026-09-04. Feeds the search-strategy classifier's visit sequence and the errors/perseverative-errors measure - not shown elsewhere yet.</p>
1211+
</details>
1212+
<div id="visitsTableWrap" hidden>
1213+
<table id="visitsTable">
1214+
<thead>
1215+
<tr><th>Hole</th><th>Frames</th><th>Events merged</th><th>Type</th><th></th></tr>
1216+
</thead>
1217+
<tbody id="visitsTableBody"></tbody>
1218+
</table>
1219+
</div>
11811220
</div>
11821221
</div>
11831222
</details>

‎app/js/app.js‎

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@ const rosterCancelBtn = document.getElementById('rosterCancelBtn');
133133
const rosterListEl = document.getElementById('rosterList');
134134
const rosterEmptyNoteEl = document.getElementById('rosterEmptyNote');
135135
const rosterLocateInput = document.getElementById('rosterLocateInput');
136+
const rosterLocatePrompt = document.getElementById('rosterLocatePrompt');
137+
const rosterLocatePromptText = document.getElementById('rosterLocatePromptText');
138+
const rosterLocateConfirmBtn = document.getElementById('rosterLocateConfirmBtn');
139+
const rosterLocateCancelBtn = document.getElementById('rosterLocateCancelBtn');
136140
const rosterClearBtn = document.getElementById('rosterClearBtn');
137141
const rosterClearConfirm = document.getElementById('rosterClearConfirm');
138142
const rosterClearConfirmText = document.getElementById('rosterClearConfirmText');
@@ -250,24 +254,42 @@ async function renderRosterList() {
250254

251255
async function handleRosterClick(key, fileName) {
252256
if (key === currentVideoKey || inputLocked) return;
257+
// Clicking any roster row dismisses a stale locate-prompt left over
258+
// from a previous attempt at a different (or the same) video.
259+
rosterLocateKey = null;
260+
rosterLocatePrompt.hidden = true;
253261
const file = await resolveFileForKey(key);
254262
if (file) {
255263
await loadVideoOrSwitch(file);
256264
return;
257265
}
258266
// Only reachable for a video an Open folder/Select videos pick named
259267
// but that was never actually loaded in any session - nothing to
260-
// recover, so ask for it back rather than silently doing nothing.
268+
// recover. Shows an inline prompt with its own button rather than
269+
// firing the native file picker straight from this click - a system
270+
// dialog appearing with no lead-up read as "it's in your list, but
271+
// not really, go find it yourself," found by hands-on testing.
261272
rosterLocateKey = key;
262-
setStatus(`Locate ${fileName} to switch to it - it hasn't been opened yet, so there's no saved copy to switch to instantly.`, 'info');
263-
rosterLocateInput.click();
273+
rosterLocatePromptText.textContent =
274+
`"${fileName}" hasn't been opened yet, so there's no saved copy to switch to instantly - locate it on your computer to open it.`;
275+
rosterLocatePrompt.hidden = false;
264276
}
265277

278+
rosterLocateConfirmBtn.addEventListener('click', () => {
279+
rosterLocateInput.click();
280+
});
281+
282+
rosterLocateCancelBtn.addEventListener('click', () => {
283+
rosterLocateKey = null;
284+
rosterLocatePrompt.hidden = true;
285+
});
286+
266287
rosterLocateInput.addEventListener('change', async () => {
267288
const picked = rosterLocateInput.files[0];
268289
rosterLocateInput.value = '';
269290
const key = rosterLocateKey;
270291
rosterLocateKey = null;
292+
rosterLocatePrompt.hidden = true;
271293
if (!picked || !key) return;
272294
if (videoKeyFor(picked) !== key) {
273295
setStatus(`That file's name/size/modified-date don't match what was recorded for this entry - pick the original file.`, 'error');
@@ -286,9 +308,11 @@ rosterClearBtn.addEventListener('click', async () => {
286308
return;
287309
}
288310
rosterClearConfirmText.textContent =
289-
`Remove all ${entries.length} video${entries.length === 1 ? '' : 's'} from this list and delete ` +
290-
`their saved ROI/tracking work${currentVideoKey ? ' - including whatever is open right now' : ''}? ` +
291-
`This cannot be undone; your original video files on disk are untouched.`;
311+
`Before you clear this out - if there's a video here you want to keep, save its project file first ` +
312+
`(look for "Save project file" once it's tracked; it downloads as an easy-to-spot "..._AUDITED.bmp.json" ` +
313+
`file you can keep next to the video). Clearing removes ${entries.length} video${entries.length === 1 ? '' : 's'} ` +
314+
`from this list and their saved ROI/tracking work${currentVideoKey ? ", including whatever's open right now" : ''}. ` +
315+
`Your original video files on disk are safe either way.`;
292316
rosterClearBtn.hidden = true;
293317
rosterClearConfirm.hidden = false;
294318
});
@@ -964,6 +988,52 @@ const eventsTableWrap = document.getElementById('eventsTableWrap');
964988
const eventsTableBody = document.getElementById('eventsTableBody');
965989
const visitsTableWrap = document.getElementById('visitsTableWrap');
966990
const visitsTableBody = document.getElementById('visitsTableBody');
991+
const eventsSubTabs = document.getElementById('eventsSubTabs');
992+
const tabDetectedEvents = document.getElementById('tab-detected-events');
993+
const tabMergedVisits = document.getElementById('tab-merged-visits');
994+
const panelDetectedEvents = document.getElementById('panel-detected-events');
995+
const panelMergedVisits = document.getElementById('panel-merged-visits');
996+
997+
// Detected events / Merged visits as two tabs, not two stacked full-page
998+
// tables - a real trial can produce well over a thousand raw event rows
999+
// before merging, and scrolling past all of them to reach the (usually
1000+
// much shorter) merged table was the actual complaint. Same real ARIA
1001+
// tablist mechanism as the Video/Path/Heat map/Raster tabs above (not a
1002+
// new pattern) - deliberately a separate small table rather than
1003+
// generalizing VIZ_TABS, since this pair has no per-tab onActivate work.
1004+
const EVENTS_SUB_TABS = [
1005+
{ tab: tabDetectedEvents, panel: panelDetectedEvents },
1006+
{ tab: tabMergedVisits, panel: panelMergedVisits },
1007+
];
1008+
1009+
function activateEventsSubTab(target) {
1010+
for (const { tab, panel } of EVENTS_SUB_TABS) {
1011+
const active = tab === target;
1012+
tab.setAttribute('aria-selected', active ? 'true' : 'false');
1013+
tab.tabIndex = active ? 0 : -1;
1014+
panel.hidden = !active;
1015+
}
1016+
}
1017+
1018+
for (const { tab } of EVENTS_SUB_TABS) {
1019+
tab.addEventListener('click', () => activateEventsSubTab(tab));
1020+
}
1021+
1022+
eventsSubTabs.addEventListener('keydown', (e) => {
1023+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return;
1024+
const tabs = EVENTS_SUB_TABS.map((v) => v.tab);
1025+
const currentIndex = tabs.indexOf(document.activeElement);
1026+
if (currentIndex === -1) return;
1027+
let nextIndex;
1028+
if (e.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
1029+
else if (e.key === 'ArrowRight') nextIndex = (currentIndex + 1) % tabs.length;
1030+
else if (e.key === 'Home') nextIndex = 0;
1031+
else nextIndex = tabs.length - 1;
1032+
const next = tabs[nextIndex];
1033+
next.focus();
1034+
activateEventsSubTab(next);
1035+
e.preventDefault();
1036+
});
9671037
const measuresBtn = document.getElementById('measuresBtn');
9681038
const measuresStatusEl = document.getElementById('measuresStatus');
9691039
const measuresTableWrap = document.getElementById('measuresTableWrap');
@@ -1779,6 +1849,7 @@ async function loadVideo(file, { isResume = false, keepLocked = false } = {}) {
17791849
trackStatusEl.textContent = '';
17801850
eventsSection.hidden = true;
17811851
eventsQcSection.hidden = true;
1852+
eventsSubTabs.hidden = true;
17821853
eventsTableWrap.hidden = true;
17831854
visitsTableWrap.hidden = true;
17841855
eventsStatusEl.textContent = '';
@@ -2762,6 +2833,10 @@ function runEventDetection() {
27622833
const visits = mergeVisits(events, trackResult.holeSignals.centerDensity, { minCenterDensity, reengageFraction });
27632834
renderVisitsTable(visits);
27642835
visitsTableWrap.hidden = visits.length === 0;
2836+
// The tab pair only makes sense once there's something to switch
2837+
// between - stays hidden (from the reset block above) if this
2838+
// trial produced nothing on either side.
2839+
eventsSubTabs.hidden = events.length === 0 && visits.length === 0;
27652840

27662841
currentVisits = visits;
27672842
tabRaster.disabled = false;

0 commit comments

Comments
 (0)