From f83b0adc7c0e5d188e85c30346ba471a7b93ba06 Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:14:05 -0600 Subject: [PATCH 1/3] fix(viewer): Fit video within available viewport Co-authored-by: Codex --- src/artifacts/viewer.test.ts | 19 ++++++++++-- src/artifacts/viewer.ts | 59 +++++++++++++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/artifacts/viewer.test.ts b/src/artifacts/viewer.test.ts index ffacada..c563987 100644 --- a/src/artifacts/viewer.test.ts +++ b/src/artifacts/viewer.test.ts @@ -179,9 +179,24 @@ describe('canonical evidence viewer', () => { }); expect(html).toContain('width="1024" height="768"'); - expect(html).toContain(`.video-container video { + expect(html).toContain( + '
', + ); + expect(html).toContain(`.video-container.video-fit video { width: 100%; - height: auto;`); + height: 100%;`); + expect(html).toContain(`body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif; + background: #0d1117; + color: #c9d1d9; + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden;`); + expect(html).toContain(`.viewer { + display: flex; + flex: 1 1 auto; + min-height: 0;`); }); it('omits video dimensions when the recorded viewport is not usable', () => { diff --git a/src/artifacts/viewer.ts b/src/artifacts/viewer.ts index 1b7acfa..50b32da 100644 --- a/src/artifacts/viewer.ts +++ b/src/artifacts/viewer.ts @@ -325,6 +325,9 @@ export function generateViewer(data: ViewerData): string { const videoDimensions = recordedViewport ? ` width="${recordedViewport.width}" height="${recordedViewport.height}"` : ''; + const videoContainerAttributes = recordedViewport + ? ` class="video-container video-fit" style="aspect-ratio: ${recordedViewport.width} / ${recordedViewport.height}"` + : ' class="video-container"'; // Build marker data for the scrub bar const markersJson = serializeInlineJson( @@ -361,7 +364,7 @@ export function generateViewer(data: ViewerData): string { const videoPanelHtml = hasVideo ? `
-
+
@@ -474,10 +477,14 @@ export function generateViewer(data: ViewerData): string { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif; background: #0d1117; color: #c9d1d9; - min-height: 100vh; + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; } .header { + flex: 0 0 auto; padding: 24px 32px; border-bottom: 1px solid #21262d; background: #161b22; @@ -687,15 +694,15 @@ export function generateViewer(data: ViewerData): string { .viewer { display: flex; - height: calc(100vh - 180px); - min-height: 400px; + flex: 1 1 auto; + min-height: 0; } .video-panel { flex: 0 0 62%; padding: 16px; display: flex; - align-items: flex-start; + align-items: stretch; justify-content: center; background: #0d1117; overflow: hidden; @@ -703,8 +710,11 @@ export function generateViewer(data: ViewerData): string { .video-wrapper { width: 100%; + height: 100%; + min-height: 0; display: flex; flex-direction: column; + align-items: center; } .video-container { @@ -712,6 +722,13 @@ export function generateViewer(data: ViewerData): string { width: 100%; } + .video-container.video-fit { + flex: 1 1 auto; + min-height: 0; + width: auto; + max-width: 100%; + } + .video-container video { width: 100%; height: auto; @@ -720,6 +737,11 @@ export function generateViewer(data: ViewerData): string { display: block; } + .video-container.video-fit video { + width: 100%; + height: 100%; + } + .video-overlay { position: absolute; top: 0; @@ -734,6 +756,7 @@ export function generateViewer(data: ViewerData): string { /* Scrub bar */ .scrub-bar { position: relative; + flex: 0 0 auto; width: 100%; padding: 8px 0 6px; background: #161b22; @@ -1144,10 +1167,26 @@ export function generateViewer(data: ViewerData): string { .timeline-panel::-webkit-scrollbar-thumb:hover { background: #484f58; } @media (max-width: 768px) { + body { + height: auto; + min-height: 100vh; + display: block; + overflow: auto; + } .viewer { flex-direction: column; height: auto; } + .video-wrapper { + height: auto; + } + .video-container.video-fit { + flex: none; + width: 100%; + } + .video-container.video-fit video { + height: auto; + } .video-panel, .timeline-panel { flex: none; width: 100%; @@ -1278,6 +1317,7 @@ ${stepsHtml} const steps = document.querySelectorAll('.step'); const timelinePanel = document.querySelector('.timeline-panel'); const overlay = document.querySelector('.video-overlay'); + const videoContainer = document.querySelector('.video-container'); const entries = ${entriesJson}; let duration = ${timelineDurationSec}; const markers = ${markersJson}; @@ -1633,6 +1673,14 @@ ${stepsHtml} // Highlight active step as video plays (only if video exists) if (video) { + function fitVideoToAvailableSpace() { + if (!videoContainer || video.videoWidth <= 0 || video.videoHeight <= 0) return; + videoContainer.classList.add('video-fit'); + videoContainer.style.aspectRatio = video.videoWidth + ' / ' + video.videoHeight; + } + + if (video.readyState >= 1) fitVideoToAvailableSpace(); + video.addEventListener('timeupdate', () => { const t = video.currentTime; let activeStep = null; @@ -1666,6 +1714,7 @@ ${stepsHtml} // Preserve the canonical action timeline when media is shorter. video.addEventListener('loadedmetadata', () => { + fitVideoToAvailableSpace(); if (video.duration && isFinite(video.duration)) { duration = Math.max(duration, video.duration); scrubMarkers.forEach(m => { From eb28a831e4317a1570d8fc72e38d4f54510d879c Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:37:13 -0600 Subject: [PATCH 2/3] no-mistakes(review): Align scrub bar to fitted video and cap header height --- src/artifacts/viewer.test.ts | 50 +++++++++++++++++++++++----------- src/artifacts/viewer.ts | 52 +++++++++++++++++++++++++++--------- 2 files changed, 75 insertions(+), 27 deletions(-) diff --git a/src/artifacts/viewer.test.ts b/src/artifacts/viewer.test.ts index c563987..d1aa655 100644 --- a/src/artifacts/viewer.test.ts +++ b/src/artifacts/viewer.test.ts @@ -2,6 +2,14 @@ import { describe, expect, it } from 'vitest'; import type { CanonicalEvidence, Verdict } from './evidence.js'; import { generateViewer } from './viewer.js'; +/** Body of the first stylesheet rule for `selector`, so assertions can target single declarations. */ +function styleRule(html: string, selector: string): string { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = html.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`)); + if (!match) throw new Error(`No stylesheet rule found for "${selector}"`); + return match[1]; +} + describe('canonical evidence viewer', () => { it('keeps untimed rows non-clickable and the action timeline authoritative', () => { const evidence: CanonicalEvidence = { @@ -182,21 +190,33 @@ describe('canonical evidence viewer', () => { expect(html).toContain( '
', ); - expect(html).toContain(`.video-container.video-fit video { - width: 100%; - height: 100%;`); - expect(html).toContain(`body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif; - background: #0d1117; - color: #c9d1d9; - height: 100vh; - display: flex; - flex-direction: column; - overflow: hidden;`); - expect(html).toContain(`.viewer { - display: flex; - flex: 1 1 auto; - min-height: 0;`); + expect(html).toContain('const recordedVideoSize = { width: 1024, height: 768 };'); + expect(styleRule(html, 'body')).toContain('height: 100vh'); + expect(styleRule(html, 'body')).toContain('overflow: hidden'); + expect(styleRule(html, '.viewer')).toContain('flex: 1 1 auto'); + expect(styleRule(html, '.viewer')).toContain('min-height: 0'); + expect(styleRule(html, '.video-container.video-fit')).toContain('width: 100%'); + expect(styleRule(html, '.video-container.video-fit video')).toContain( + 'height: 100%', + ); + }); + + it('keeps the scrub bar aligned with the fitted video width', () => { + const html = generateViewer({ + description: 'video sizing fixture', + serverCommand: null, + durationSec: 1, + videoFilename: 'recording.webm', + viewport: { width: 1024, height: 768 }, + entries: [], + consoleErrorCount: 0, + serverErrorCount: 0, + }); + + expect(html).toContain("videoWrapper.style.width = Math.floor(fitted) + 'px';"); + expect(styleRule(html, '.scrub-bar')).toContain('width: 100%'); + expect(styleRule(html, '.header')).toContain('max-height: 50%'); + expect(styleRule(html, '.header')).toContain('overflow-y: auto'); }); it('omits video dimensions when the recorded viewport is not usable', () => { diff --git a/src/artifacts/viewer.ts b/src/artifacts/viewer.ts index 50b32da..96de6b7 100644 --- a/src/artifacts/viewer.ts +++ b/src/artifacts/viewer.ts @@ -328,6 +328,9 @@ export function generateViewer(data: ViewerData): string { const videoContainerAttributes = recordedViewport ? ` class="video-container video-fit" style="aspect-ratio: ${recordedViewport.width} / ${recordedViewport.height}"` : ' class="video-container"'; + const recordedVideoSizeJs = recordedViewport + ? `{ width: ${recordedViewport.width}, height: ${recordedViewport.height} }` + : 'null'; // Build marker data for the scrub bar const markersJson = serializeInlineJson( @@ -485,6 +488,8 @@ export function generateViewer(data: ViewerData): string { .header { flex: 0 0 auto; + max-height: 50%; + overflow-y: auto; padding: 24px 32px; border-bottom: 1px solid #21262d; background: #161b22; @@ -710,11 +715,11 @@ export function generateViewer(data: ViewerData): string { .video-wrapper { width: 100%; + max-width: 100%; height: 100%; min-height: 0; display: flex; flex-direction: column; - align-items: center; } .video-container { @@ -723,10 +728,9 @@ export function generateViewer(data: ViewerData): string { } .video-container.video-fit { - flex: 1 1 auto; + flex: 0 0 auto; min-height: 0; - width: auto; - max-width: 100%; + width: 100%; } .video-container video { @@ -1287,6 +1291,7 @@ ${stepsHtml} const isClamped = desc.classList.contains('clamped'); desc.classList.toggle('clamped'); btn.textContent = isClamped ? 'Show less' : 'Show more'; + fitVideoToAvailableSpace(); } initDescription(); @@ -1318,6 +1323,9 @@ ${stepsHtml} const timelinePanel = document.querySelector('.timeline-panel'); const overlay = document.querySelector('.video-overlay'); const videoContainer = document.querySelector('.video-container'); + const videoWrapper = document.querySelector('.video-wrapper'); + const scrubBar = document.querySelector('.scrub-bar'); + const recordedVideoSize = ${recordedVideoSizeJs}; const entries = ${entriesJson}; let duration = ${timelineDurationSec}; const markers = ${markersJson}; @@ -1329,6 +1337,34 @@ ${stepsHtml} const scrubTooltip = document.getElementById('scrubTooltip'); const scrubMarkers = document.querySelectorAll('.scrub-marker'); + // --- Video sizing --- + // The wrapper is the shared-width card: the aspect ratio drives the container + // height, and the fitted width is applied to the wrapper so the scrub bar matches. + function videoIntrinsicSize() { + if (video && video.videoWidth > 0 && video.videoHeight > 0) { + return { width: video.videoWidth, height: video.videoHeight }; + } + return recordedVideoSize; + } + + function fitVideoToAvailableSpace() { + if (!video || !videoContainer || !videoWrapper) return; + const size = videoIntrinsicSize(); + if (!size) return; + videoContainer.classList.add('video-fit'); + videoContainer.style.aspectRatio = size.width + ' / ' + size.height; + videoWrapper.style.width = ''; + if (window.matchMedia('(max-width: 768px)').matches) return; + const availableWidth = videoWrapper.clientWidth; + const availableHeight = videoWrapper.clientHeight - (scrubBar ? scrubBar.offsetHeight : 0); + if (availableWidth <= 0 || availableHeight <= 0) return; + const fitted = Math.min(availableWidth, (availableHeight * size.width) / size.height); + videoWrapper.style.width = Math.floor(fitted) + 'px'; + } + + fitVideoToAvailableSpace(); + window.addEventListener('resize', fitVideoToAvailableSpace); + // --- Toggle state --- const toggleOverlays = document.getElementById('toggle-overlays'); @@ -1673,14 +1709,6 @@ ${stepsHtml} // Highlight active step as video plays (only if video exists) if (video) { - function fitVideoToAvailableSpace() { - if (!videoContainer || video.videoWidth <= 0 || video.videoHeight <= 0) return; - videoContainer.classList.add('video-fit'); - videoContainer.style.aspectRatio = video.videoWidth + ' / ' + video.videoHeight; - } - - if (video.readyState >= 1) fitVideoToAvailableSpace(); - video.addEventListener('timeupdate', () => { const t = video.currentTime; let activeStep = null; From 31f6e71a970b203544ad0cf9bae8d20934a9d02e Mon Sep 17 00:00:00 2001 From: Alvaro Hulse <67383925+alvarohulse@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:55:28 -0600 Subject: [PATCH 3/3] no-mistakes(document): Document viewer window-fit behavior and refresh sizing comment --- content/docs/concepts/interactive-viewer.mdx | 2 +- src/artifacts/viewer.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/content/docs/concepts/interactive-viewer.mdx b/content/docs/concepts/interactive-viewer.mdx index 1336a1c..554ad12 100644 --- a/content/docs/concepts/interactive-viewer.mdx +++ b/content/docs/concepts/interactive-viewer.mdx @@ -50,7 +50,7 @@ Overlays are scaled from the original viewport size to the current video display **Sync.** Playing the video highlights current actions and timed evidence rows. Untimed or non-finite rows cannot seek. If the action timeline extends beyond the recording, the viewer warns instead of shortening the timeline and clamps seeks to available media. -**Responsive.** On smaller screens, the video and timeline stack vertically. +**Responsive.** On desktop the viewer fits the browser window instead of scrolling the page: the header is capped at half the window height (scrolling internally if it overflows) and the video is sized to fit the remaining space, so the whole recording, its native controls, and the scrub bar stay visible at the recorded aspect ratio. On narrow screens (768px and below) the video and timeline stack vertically and the page scrolls normally. ## How overlays work diff --git a/src/artifacts/viewer.ts b/src/artifacts/viewer.ts index 96de6b7..6942193 100644 --- a/src/artifacts/viewer.ts +++ b/src/artifacts/viewer.ts @@ -319,8 +319,10 @@ export function generateViewer(data: ViewerData): string { : ''; const hasVideo = !!data.videoFilename; - // Intrinsic dimensions let the browser reserve the recorded aspect ratio on the first frame - // instead of the 300x150 unloaded-media fallback. Pairs with `height: auto` in `.video-container video`. + // The recorded viewport is the only aspect-ratio source before metadata loads, so it feeds + // the intrinsic `width`/`height` attributes, the `.video-fit` container ratio, and the + // `recordedVideoSize` fallback used to fit the video. Without it the browser reserves the + // 300x150 unloaded-media box; legacy sessions lack it and only fit once metadata arrives. const recordedViewport = normalizeViewport(data.viewport); const videoDimensions = recordedViewport ? ` width="${recordedViewport.width}" height="${recordedViewport.height}"`