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.test.ts b/src/artifacts/viewer.test.ts index ffacada..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 = { @@ -179,9 +187,36 @@ describe('canonical evidence viewer', () => { }); expect(html).toContain('width="1024" height="768"'); - expect(html).toContain(`.video-container video { - width: 100%; - height: auto;`); + expect(html).toContain( + '
', + ); + 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 1b7acfa..6942193 100644 --- a/src/artifacts/viewer.ts +++ b/src/artifacts/viewer.ts @@ -319,12 +319,20 @@ 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}"` : ''; + 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( @@ -361,7 +369,7 @@ export function generateViewer(data: ViewerData): string { const videoPanelHtml = hasVideo ? `
-
+
@@ -474,10 +482,16 @@ 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; + max-height: 50%; + overflow-y: auto; padding: 24px 32px; border-bottom: 1px solid #21262d; background: #161b22; @@ -687,15 +701,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,6 +717,9 @@ export function generateViewer(data: ViewerData): string { .video-wrapper { width: 100%; + max-width: 100%; + height: 100%; + min-height: 0; display: flex; flex-direction: column; } @@ -712,6 +729,12 @@ export function generateViewer(data: ViewerData): string { width: 100%; } + .video-container.video-fit { + flex: 0 0 auto; + min-height: 0; + width: 100%; + } + .video-container video { width: 100%; height: auto; @@ -720,6 +743,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 +762,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 +1173,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%; @@ -1248,6 +1293,7 @@ ${stepsHtml} const isClamped = desc.classList.contains('clamped'); desc.classList.toggle('clamped'); btn.textContent = isClamped ? 'Show less' : 'Show more'; + fitVideoToAvailableSpace(); } initDescription(); @@ -1278,6 +1324,10 @@ ${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 videoWrapper = document.querySelector('.video-wrapper'); + const scrubBar = document.querySelector('.scrub-bar'); + const recordedVideoSize = ${recordedVideoSizeJs}; const entries = ${entriesJson}; let duration = ${timelineDurationSec}; const markers = ${markersJson}; @@ -1289,6 +1339,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'); @@ -1666,6 +1744,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 => {