@@ -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
515583JS = """
@@ -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