Skip to content

Commit c041389

Browse files
committed
feat: in-page lightbox for visualize_tests results
Clicking an image now opens a keyboard-driven <dialog> lightbox instead of navigating to the raw PNG: arrows cycle the test's actual/expected/diff images, j/k walks the visible (filtered) tests, and 'a' toggles approval with the cp command updating live.
1 parent 82f2d3f commit c041389

1 file changed

Lines changed: 204 additions & 0 deletions

File tree

tools/visualize_tests.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,74 @@ def get_test_images() -> Iterator[TestImage]:
510510
text-align: center;
511511
color: var(--muted);
512512
}
513+
514+
dialog#lightbox {
515+
padding: 0;
516+
border: 1px solid var(--border);
517+
border-radius: 8px;
518+
background: var(--bg);
519+
color: var(--fg);
520+
max-width: 95vw;
521+
max-height: 92vh;
522+
}
523+
524+
dialog#lightbox::backdrop {
525+
background: rgba(0, 0, 0, 0.6);
526+
}
527+
528+
.lb-header,
529+
.lb-footer {
530+
display: flex;
531+
gap: 12px;
532+
align-items: baseline;
533+
padding: 8px 12px;
534+
font-size: 12px;
535+
}
536+
537+
.lb-header { border-bottom: 1px solid var(--border); }
538+
539+
.lb-header .status-badge { align-self: baseline; }
540+
541+
.lb-footer {
542+
border-top: 1px solid var(--border);
543+
color: var(--muted);
544+
justify-content: center;
545+
}
546+
547+
.lb-name {
548+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco,
549+
Consolas, monospace;
550+
font-size: 13px;
551+
word-break: break-all;
552+
}
553+
554+
.lb-sibling {
555+
text-transform: uppercase;
556+
letter-spacing: 0.04em;
557+
color: var(--muted);
558+
}
559+
560+
.lb-approved {
561+
color: var(--green);
562+
visibility: hidden;
563+
}
564+
565+
.lb-counter {
566+
margin-left: auto;
567+
color: var(--muted);
568+
font-variant-numeric: tabular-nums;
569+
}
570+
571+
dialog#lightbox img {
572+
display: block;
573+
background: var(--img-bg);
574+
max-width: calc(95vw - 2px);
575+
/* 80px ~= header + footer; a long wrapped name scrolls instead */
576+
max-height: calc(92vh - 80px);
577+
width: auto;
578+
height: auto;
579+
margin: 0 auto;
580+
}
513581
"""
514582

515583
JS = """
@@ -731,6 +799,126 @@ def get_test_images() -> Iterator[TestImage]:
731799
});
732800
applyTheme();
733801
802+
// Lightbox: click an anchor-wrapped image to inspect it in-page.
803+
const lightbox = document.getElementById('lightbox');
804+
const lbImg = lightbox.querySelector('img');
805+
const lbName = lightbox.querySelector('.lb-name');
806+
const lbBadge = lightbox.querySelector('.lb-badge');
807+
const lbSibling = lightbox.querySelector('.lb-sibling');
808+
const lbApproved = lightbox.querySelector('.lb-approved');
809+
const lbCounter = lightbox.querySelector('.lb-counter');
810+
811+
let lbRows = [];
812+
let lbRowIdx = 0;
813+
let lbSiblings = [];
814+
let lbSibIdx = 0;
815+
816+
function lbRowSiblings(row) {
817+
const sibs = [];
818+
for (const fig of row.querySelectorAll('.images-side figure')) {
819+
const img = fig.querySelector('img');
820+
const cap = fig.querySelector('figcaption');
821+
if (img && cap) {
822+
sibs.push({
823+
label: cap.textContent.trim(),
824+
src: img.getAttribute('src'),
825+
});
826+
}
827+
}
828+
return sibs;
829+
}
830+
831+
function lbShow() {
832+
const row = lbRows[lbRowIdx];
833+
const sib = lbSiblings[lbSibIdx];
834+
lbImg.src = sib.src;
835+
lbName.textContent = row.dataset.subdir + '/' + row.dataset.file;
836+
lbBadge.textContent = row.dataset.status;
837+
lbBadge.className = 'lb-badge status-badge ' + row.dataset.status;
838+
lbSibling.textContent = sib.label;
839+
lbCounter.textContent = (lbRowIdx + 1) + ' / ' + lbRows.length;
840+
const cb = row.querySelector('input.approve-cb');
841+
lbApproved.style.visibility =
842+
cb && cb.checked ? 'visible' : 'hidden';
843+
}
844+
845+
function lbOpen(row, src) {
846+
const sibs = lbRowSiblings(row);
847+
if (sibs.length === 0) return;
848+
lbRows = rows.filter((r) => !r.classList.contains('hidden'));
849+
lbRowIdx = Math.max(0, lbRows.indexOf(row));
850+
lbSiblings = sibs;
851+
lbSibIdx = Math.max(
852+
0,
853+
lbSiblings.findIndex((s) => s.src === src)
854+
);
855+
lbShow();
856+
lightbox.showModal();
857+
}
858+
859+
function lbCycle(d) {
860+
const n = lbSiblings.length;
861+
lbSibIdx = (lbSibIdx + d + n) % n;
862+
lbShow();
863+
}
864+
865+
function lbMove(d) {
866+
const next = lbRowIdx + d;
867+
if (next < 0 || next >= lbRows.length) return;
868+
const want = lbSiblings[lbSibIdx].label;
869+
const sibs = lbRowSiblings(lbRows[next]);
870+
if (sibs.length === 0) return;
871+
lbRowIdx = next;
872+
lbSiblings = sibs;
873+
const keep = lbSiblings.findIndex((s) => s.label === want);
874+
lbSibIdx = keep >= 0 ? keep : 0;
875+
lbShow();
876+
}
877+
878+
function lbToggleApprove() {
879+
const cb = lbRows[lbRowIdx].querySelector('input.approve-cb');
880+
if (!cb) return;
881+
cb.checked = !cb.checked;
882+
cb.dispatchEvent(new Event('change'));
883+
lbShow();
884+
}
885+
886+
for (const link of document.querySelectorAll('.images figure a')) {
887+
link.addEventListener('click', (e) => {
888+
e.preventDefault();
889+
const row = link.closest('.test-row');
890+
const img = link.querySelector('img');
891+
if (row && img) lbOpen(row, img.getAttribute('src'));
892+
});
893+
}
894+
895+
// On document, not the dialog: clicking the (non-focusable) image
896+
// can move focus off the dialog, which would kill its key events.
897+
document.addEventListener('keydown', (e) => {
898+
if (!lightbox.open) return;
899+
if (e.metaKey || e.ctrlKey || e.altKey) return;
900+
if (e.key === 'ArrowLeft') {
901+
e.preventDefault();
902+
lbCycle(-1);
903+
} else if (e.key === 'ArrowRight') {
904+
e.preventDefault();
905+
lbCycle(1);
906+
} else if (e.key === 'ArrowDown' || e.key === 'j') {
907+
e.preventDefault();
908+
lbMove(1);
909+
} else if (e.key === 'ArrowUp' || e.key === 'k') {
910+
e.preventDefault();
911+
lbMove(-1);
912+
} else if (e.key === 'a') {
913+
e.preventDefault();
914+
lbToggleApprove();
915+
}
916+
});
917+
918+
lightbox.addEventListener('click', (e) => {
919+
if (e.target === lightbox) lightbox.close();
920+
});
921+
734922
// Set initial chip-zero highlight on load.
735923
function highlightZeroChips() {
736924
for (const chip of chips) {
@@ -790,6 +978,22 @@ def get_test_images() -> Iterator[TestImage]:
790978
<main>
791979
{rows}
792980
</main>
981+
<dialog id="lightbox">
982+
<div class="lb-header">
983+
<span class="lb-name"></span>
984+
<span class="lb-badge status-badge"></span>
985+
<span class="lb-sibling"></span>
986+
<span class="lb-approved">✓ approved</span>
987+
<span class="lb-counter"></span>
988+
</div>
989+
<img alt="">
990+
<div class="lb-footer">
991+
<span>←/→ sibling</span>
992+
<span>j/k test</span>
993+
<span>a approve</span>
994+
<span>esc close</span>
995+
</div>
996+
</dialog>
793997
<script>{js}</script>
794998
</body>
795999
</html>

0 commit comments

Comments
 (0)