Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion content/docs/concepts/interactive-viewer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 38 additions & 3 deletions src/artifacts/viewer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(
'<div class="video-container video-fit" style="aspect-ratio: 1024 / 768">',
);
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', () => {
Expand Down
93 changes: 86 additions & 7 deletions src/artifacts/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -361,7 +369,7 @@ export function generateViewer(data: ViewerData): string {

const videoPanelHtml = hasVideo
? `<div class="video-wrapper">
<div class="video-container">
<div${videoContainerAttributes}>
<video src="./${escapeHtml(data.videoFilename!)}"${videoDimensions} controls></video>
<div class="video-overlay"></div>
</div>
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -687,22 +701,25 @@ 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;
}

.video-wrapper {
width: 100%;
max-width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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%;
Expand Down Expand Up @@ -1248,6 +1293,7 @@ ${stepsHtml}
const isClamped = desc.classList.contains('clamped');
desc.classList.toggle('clamped');
btn.textContent = isClamped ? 'Show less' : 'Show more';
fitVideoToAvailableSpace();
}
initDescription();

Expand Down Expand Up @@ -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};
Expand All @@ -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');

Expand Down Expand Up @@ -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 => {
Expand Down
Loading