diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts index d9a89e5d..d8076a4b 100644 --- a/packages/app/src/app.ts +++ b/packages/app/src/app.ts @@ -232,9 +232,10 @@ export class WebdriverIODevtoolsApplication extends Element { class="flex h-[calc(100%-40px)] w-full relative" > ${ - // Only render the test-suite sidebar (and its resize slider) when the - // trace came from a testrunner — the player (standalone) has no tree, - // so the slider would otherwise show a stray dragger on hover. + // Only render the test-suite sidebar (and its resize slider) for a + // live testrunner session. The player has no run/rerun affordances, + // so the tree is dead weight even for testrunner-captured zips. + !this.dataManager.playerMode && this.dataManager.traceType === TraceType.Testrunner ? html``. Pulled out of snapshot.ts * so the main component file stays focused on the iframe/screencast logic. */ @@ -11,11 +13,7 @@ export const snapshotStyles = css` align-items: center; justify-content: center; box-sizing: border-box !important; - background: radial-gradient( - 120% 120% at 50% 0%, - var(--vscode-editorWidget-background), - var(--vscode-editor-background) - ); + background: ${unsafeCSS(BROWSER_BACKDROP_GRADIENT)}; } section { diff --git a/packages/app/src/components/browser/trace-player-controls.ts b/packages/app/src/components/browser/trace-player-controls.ts new file mode 100644 index 00000000..a882364d --- /dev/null +++ b/packages/app/src/components/browser/trace-player-controls.ts @@ -0,0 +1,137 @@ +import { Element } from '@core/element' +import { html, css, type TemplateResult } from 'lit' +import { customElement, state } from 'lit/decorators.js' + +import { emit, KBD } from '../../controller/keyboard.js' +import { + PLAYER_RESTART_EVENT, + PLAYER_SPEED_EVENT, + PLAYER_STATE_EVENT, + SPEEDS, + type PlayerState +} from './trace-timeline-constants.js' +import { formatTimecode } from './trace-timeline-utils.js' + +import '~icons/mdi/play.js' +import '~icons/mdi/pause.js' +import '~icons/mdi/skip-previous.js' +import '~icons/mdi/skip-next.js' +import '~icons/mdi/restart.js' + +const COMPONENT = 'wdio-devtools-trace-player-controls' + +/** Playback controls bar; drives the timeline via window events and mirrors its broadcast state. */ +@customElement(COMPONENT) +export class TracePlayerControls extends Element { + @state() playerState: PlayerState = { + currentMs: 0, + duration: 0, + playing: false, + speed: 1 + } + + static styles = [ + ...Element.styles, + css` + :host { + display: flex; + align-items: center; + background-color: var(--vscode-editor-background); + color: var(--vscode-foreground); + } + ` + ] + + connectedCallback(): void { + super.connectedCallback() + window.addEventListener(PLAYER_STATE_EVENT, this.#onState) + } + + disconnectedCallback(): void { + super.disconnectedCallback() + window.removeEventListener(PLAYER_STATE_EVENT, this.#onState) + } + + #onState = (event: Event): void => { + this.playerState = (event as CustomEvent).detail + } + + #button( + title: string, + icon: TemplateResult, + onClick: () => void, + extra = '' + ): TemplateResult { + return html`` + } + + #renderSpeedSelect(speed: number): TemplateResult { + return html` + + ` + } + + render() { + const { currentMs, duration, playing, speed } = this.playerState + return html` +
+ ${formatTimecode(currentMs)} + / + ${formatTimecode(duration)} + + ${this.#button( + 'Restart', + html``, + () => emit(PLAYER_RESTART_EVENT) + )} + ${this.#button( + 'Previous action', + html``, + () => emit(KBD.step, { dir: -1 }) + )} + ${this.#button( + playing ? 'Pause' : 'Play', + playing + ? html`` + : html``, + () => emit(KBD.togglePlay), + 'text-chartsBlue' + )} + ${this.#button( + 'Next action', + html``, + () => emit(KBD.step, { dir: 1 }) + )} + ${this.#renderSpeedSelect(speed)} +
+ ` + } +} + +declare global { + interface HTMLElementTagNameMap { + [COMPONENT]: TracePlayerControls + } +} diff --git a/packages/app/src/components/browser/trace-timeline-constants.ts b/packages/app/src/components/browser/trace-timeline-constants.ts index d88f1c61..9f057994 100644 --- a/packages/app/src/components/browser/trace-timeline-constants.ts +++ b/packages/app/src/components/browser/trace-timeline-constants.ts @@ -1,19 +1,23 @@ -import type { ActionCategory } from '../workbench/actionItems/category.js' - /** Playback speed multipliers offered in the timeline controls. */ export const SPEEDS = [0.5, 1, 2, 3, 5] -/** Width of the track-label gutter (px) — lanes start after it. */ -export const GUTTER = 80 +/** Candidate ruler intervals (ms); tickStep picks the smallest fitting one. */ +export const TICK_STEPS = [ + 100, 250, 500, 1_000, 2_000, 5_000, 10_000, 15_000, 30_000, 60_000, 120_000, + 300_000, 600_000 +] + +/** Ruler divisions to aim for — keeps labels readable at any duration. */ +export const TICK_TARGET_DIVISIONS = 14 -/** Right breathing room (px) so end-of-timeline markers don't hug the edge. */ -export const INSET = 14 +/** Window events linking the controls bar and the timeline strip (KBD-style). */ +export const PLAYER_STATE_EVENT = 'trace-player:state' +export const PLAYER_RESTART_EVENT = 'trace-player:restart' +export const PLAYER_SPEED_EVENT = 'trace-player:speed' -/** Tailwind background class per action category, for the timeline chips. */ -export const CATEGORY_BG: Record = { - navigation: 'bg-chartsBlue', - input: 'bg-chartsPurple', - assertion: 'bg-chartsGreen', - query: 'bg-chartsYellow', - other: 'bg-gray-500' +export interface PlayerState { + currentMs: number + duration: number + playing: boolean + speed: number } diff --git a/packages/app/src/components/browser/trace-timeline-styles.ts b/packages/app/src/components/browser/trace-timeline-styles.ts index f2e276ad..f1482adc 100644 --- a/packages/app/src/components/browser/trace-timeline-styles.ts +++ b/packages/app/src/components/browser/trace-timeline-styles.ts @@ -1,7 +1,6 @@ import { css } from 'lit' -/** Styles for the trace-player timeline: host layout, hidden scrollbars, and - * the network-detail drawer. Detail-block styles come from networkStyles. */ +/** Host layout for the trace-player timeline strip. */ export const timelineStyles = css` :host { position: relative; @@ -12,53 +11,4 @@ export const timelineStyles = css` background-color: var(--vscode-editor-background); color: var(--vscode-foreground); } - .no-scrollbar { - scrollbar-width: none; - -ms-overflow-style: none; - } - .no-scrollbar::-webkit-scrollbar { - display: none; - } - .net-drawer { - position: absolute; - left: 0; - right: 0; - bottom: 0; - max-height: 62%; - display: flex; - flex-direction: column; - background: var(--vscode-sideBar-background); - border-top: 1px solid var(--accent, #ff7a3c); - box-shadow: 0 -16px 40px -24px #000; - z-index: 30; - } - .net-drawer-head { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 10px; - border-bottom: 1px solid var(--vscode-panel-border); - font-size: 12px; - } - .net-drawer-head .url { - font-family: monospace; - font-size: 11.5px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - opacity: 0.85; - } - .net-drawer-head .close { - margin-left: auto; - cursor: pointer; - padding: 2px 6px; - border-radius: 4px; - } - .net-drawer-head .close:hover { - background: var(--vscode-toolbar-hoverBackground); - } - .net-drawer-body { - overflow: auto; - padding: 4px 0; - } ` diff --git a/packages/app/src/components/browser/trace-timeline-utils.ts b/packages/app/src/components/browser/trace-timeline-utils.ts index 1083ee65..ef99a363 100644 --- a/packages/app/src/components/browser/trace-timeline-utils.ts +++ b/packages/app/src/components/browser/trace-timeline-utils.ts @@ -1,9 +1,37 @@ +import { + TICK_STEPS, + TICK_TARGET_DIVISIONS +} from './trace-timeline-constants.js' + /** Detect image mime from a base64 string's magic bytes — trace screenshots * may be PNG (polling capture) or JPEG (CDP), and the zip names both `.jpeg`. */ export function imageMime(base64: string): string { return base64.startsWith('/9j/') ? 'image/jpeg' : 'image/png' } +export function tickStep( + durationMs: number, + targetTicks = TICK_TARGET_DIVISIONS +): number { + const raw = durationMs / targetTicks + return ( + TICK_STEPS.find((step) => step >= raw) ?? TICK_STEPS[TICK_STEPS.length - 1] + ) +} + +/** Ruler tick label: `500ms`, `3.5s`, `1:15`. */ +export function formatTickLabel(ms: number): string { + if (ms < 1_000) { + return `${ms}ms` + } + if (ms < 60_000) { + return `${(ms / 1_000).toFixed(1)}s` + } + const minutes = Math.floor(ms / 60_000) + const seconds = Math.round((ms % 60_000) / 1_000) + return `${minutes}:${String(seconds).padStart(2, '0')}` +} + /** `m:ss.cc` timecode (e.g. 32_270ms → `0:32.27`). */ export function formatTimecode(ms: number): string { const safe = Number.isFinite(ms) && ms > 0 ? ms : 0 diff --git a/packages/app/src/components/browser/trace-timeline.ts b/packages/app/src/components/browser/trace-timeline.ts index c2a62d52..21f7ffce 100644 --- a/packages/app/src/components/browser/trace-timeline.ts +++ b/packages/app/src/components/browser/trace-timeline.ts @@ -1,42 +1,30 @@ import { Element } from '@core/element' -import { html, nothing, type TemplateResult } from 'lit' +import { html, type TemplateResult } from 'lit' import { customElement, state, query } from 'lit/decorators.js' import { consume } from '@lit/context' import type { CommandLog, TracePlayerFrame } from '@wdio/devtools-shared' -import { - commandContext, - framesContext, - networkRequestContext -} from '../../controller/context.js' -import { commandCategory } from '../workbench/actionItems/category.js' +import { commandContext, framesContext } from '../../controller/context.js' import { activeTimestampAt } from '../workbench/active-entry.js' -import { networkStyles } from '../workbench/network/styles.js' -import { renderNetworkRequestDetail } from '../workbench/network/request-detail.js' import { KBD } from '../../controller/keyboard.js' import { - CATEGORY_BG, - GUTTER, - INSET, - SPEEDS + PLAYER_RESTART_EVENT, + PLAYER_SPEED_EVENT, + PLAYER_STATE_EVENT, + SPEEDS, + type PlayerState } from './trace-timeline-constants.js' -import { formatTimecode, imageMime } from './trace-timeline-utils.js' +import { + formatTickLabel, + formatTimecode, + imageMime, + tickStep +} from './trace-timeline-utils.js' import { timelineStyles } from './trace-timeline-styles.js' -import '~icons/mdi/play.js' -import '~icons/mdi/pause.js' -import '~icons/mdi/skip-previous.js' -import '~icons/mdi/skip-next.js' -import '~icons/mdi/restart.js' - const COMPONENT = 'wdio-devtools-trace-timeline' -/** - * Trace-player timeline (replaces the workbench dock in `pnpm show-trace` - * mode). Owns the playback clock, the screenshot filmstrip, the per-track - * timeline (actions / network / console), and the playhead. Advancing the - * clock dispatches `show-command` so the reused browser pane swaps screenshots. - */ +/** Player timeline strip: owns the playback clock, filmstrip, and playhead; wired to the controls bar and keyboard via window events, and drives the workbench via `show-command`. */ @customElement(COMPONENT) export class TraceTimeline extends Element { @consume({ context: commandContext, subscribe: true }) @@ -47,10 +35,6 @@ export class TraceTimeline extends Element { @state() frames: TracePlayerFrame[] = [] - @consume({ context: networkRequestContext, subscribe: true }) - @state() - networkRequests: NetworkRequest[] = [] - /** Playback position in ms relative to the recording start. */ @state() currentMs = 0 @state() playing = false @@ -61,14 +45,11 @@ export class TraceTimeline extends Element { #activeTimestamp?: number #started = false - @query('[data-lanes]') lanesEl?: HTMLElement + @query('[data-scrub]') scrubEl?: HTMLElement #dragging = false - /** Network request whose detail drawer is open, or undefined. */ - @state() selectedRequest?: NetworkRequest - - static styles = [...Element.styles, networkStyles, timelineStyles] + static styles = [...Element.styles, timelineStyles] connectedCallback(): void { super.connectedCallback() @@ -76,6 +57,8 @@ export class TraceTimeline extends Element { window.addEventListener(KBD.step, this.#onKbdStep) window.addEventListener(KBD.jump, this.#onKbdJump) window.addEventListener(KBD.speed, this.#onKbdSpeed) + window.addEventListener(PLAYER_RESTART_EVENT, this.#onRestartEvent) + window.addEventListener(PLAYER_SPEED_EVENT, this.#onSpeedEvent) } disconnectedCallback(): void { @@ -87,6 +70,13 @@ export class TraceTimeline extends Element { window.removeEventListener(KBD.step, this.#onKbdStep) window.removeEventListener(KBD.jump, this.#onKbdJump) window.removeEventListener(KBD.speed, this.#onKbdSpeed) + window.removeEventListener(PLAYER_RESTART_EVENT, this.#onRestartEvent) + window.removeEventListener(PLAYER_SPEED_EVENT, this.#onSpeedEvent) + } + + #onRestartEvent = (): void => this.#restart() + #onSpeedEvent = (event: Event): void => { + this.speed = (event as CustomEvent<{ value: number }>).detail.value } #onKbdTogglePlay = (): void => this.#togglePlay() @@ -152,6 +142,17 @@ export class TraceTimeline extends Element { if (!this.#started && this.commands.length) { this.#syncActiveCommand() } + // Mirror playback state to the controls bar on the tab-header line. + window.dispatchEvent( + new CustomEvent(PLAYER_STATE_EVENT, { + detail: { + currentMs: this.currentMs, + duration: this.#duration, + playing: this.playing, + speed: this.speed + } + }) + ) } #stopRaf(): void { @@ -243,23 +244,14 @@ export class TraceTimeline extends Element { } } - #onSpeedChange(event: Event): void { - this.speed = Number((event.target as HTMLSelectElement).value) - } - - // ─── scrubbing (free-flow playhead drag) ─────────────────────────────────── + // ─── scrubbing (drag anywhere on the strip) ─────────────────────────────── #fractionFromClientX(clientX: number): number { - const rect = this.lanesEl?.getBoundingClientRect() - if (!rect) { + const rect = this.scrubEl?.getBoundingClientRect() + if (!rect || rect.width <= 0) { return 0 } - const laneStart = rect.left + GUTTER - const laneWidth = rect.width - GUTTER - INSET - if (laneWidth <= 0) { - return 0 - } - return Math.min(1, Math.max(0, (clientX - laneStart) / laneWidth)) + return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)) } #onPointerDown = (event: PointerEvent): void => { @@ -290,72 +282,6 @@ export class TraceTimeline extends Element { // ─── render ─────────────────────────────────────────────────────────────── - #ctrlButton( - title: string, - icon: TemplateResult, - onClick: () => void, - extra = '' - ): TemplateResult { - return html`` - } - - #renderControls(): TemplateResult { - return html` -
- ${this.#ctrlButton( - 'Restart', - html``, - () => this.#restart() - )} - ${this.#ctrlButton( - 'Previous action', - html``, - () => this.#step(-1) - )} - ${this.#ctrlButton( - this.playing ? 'Pause' : 'Play', - this.playing - ? html`` - : html``, - () => this.#togglePlay(), - 'text-chartsBlue' - )} - ${this.#ctrlButton( - 'Next action', - html``, - () => this.#step(1) - )} - ${formatTimecode(this.currentMs)} - / - ${formatTimecode(this.#duration)} - -
- ` - } - /** Timestamp of the frame nearest the playhead — drives filmstrip highlight. */ get #activeFrameTimestamp(): number | undefined { const clock = this.#start + this.currentMs @@ -371,167 +297,113 @@ export class TraceTimeline extends Element { return best } - // CSS left for a marker inside a track body (which starts after the gutter), - // leaving INSET of right margin so end-of-timeline markers don't hug the edge. - #laneLeft(fraction: number): string { - return `calc(${fraction} * (100% - ${INSET}px))` + get #ticks(): number[] { + const step = tickStep(this.#duration) + const out: number[] = [] + for (let t = step; t < this.#duration; t += step) { + out.push(t) + } + return out } - #renderFilmstrip(): TemplateResult { - if (!this.frames.length) { - return html`
- No frames captured -
` - } - const activeFrame = this.#activeFrameTimestamp - return html` -
-
-
- ${this.frames.map( - (frame) => - html`` - )} -
-
- ` + // Faint vertical gridlines at each ruler tick, spanning the whole strip. + #renderGridlines(): TemplateResult { + return html`${this.#ticks.map( + (tick) => + html`
` + )}` } - #renderTrack( - label: string, - body: TemplateResult | typeof nothing - ): TemplateResult { + // Ruler labels stay inside the strip via the bounded translateX trick. + #renderRulerLabels(): TemplateResult { return html` -
-
- ${label} -
-
${body}
+
+ ${this.#ticks.map((tick) => { + const fraction = tick / this.#duration + return html`${formatTickLabel(tick)}` + })}
` } - #renderActionsTrack(): TemplateResult { - const body = html`${this.#sortedCommands.map((command) => { - const ts = command.timestamp ?? 0 - const fraction = this.#fraction(ts) - const active = ts === this.#activeTimestamp - const color = CATEGORY_BG[commandCategory(command.command)] - // Track chips stay compact with the short command name; the full - // Playwright label is the hover tooltip (and the left Actions list). - return html`` - })}` - return this.#renderTrack('Actions', body) - } - - #renderNetworkTrack(): TemplateResult { - if (!this.networkRequests.length) { - return this.#renderTrack('Network', nothing) - } - const body = html`${this.networkRequests.map((request) => { - const leftFr = this.#fraction(request.startTime) - const rawFr = Math.max(0.004, (request.time ?? 0) / this.#duration) - const widthFr = Math.min(rawFr, 1 - leftFr) - const selected = this.selectedRequest?.id === request.id - // stopPropagation so a click selects the request rather than scrubbing the - // playhead (the lanes container owns the pointerdown drag handler). + // Thumbnails sit at their wall-clock position along the axis. + #renderThumbTrack(): TemplateResult { + if (!this.frames.length) { return html`
` - })}` - return this.#renderTrack('Network', body) - } - - #renderNetworkDrawer(): TemplateResult | typeof nothing { - const req = this.selectedRequest - if (!req) { - return nothing + class="flex-1 min-h-0 flex items-center justify-center text-[11px] opacity-50" + > + No frames captured +
` } + const activeFrame = this.#activeFrameTimestamp return html` -
-
- ${req.method} ${req.url} - + ${this.frames.map((frame) => { + const fraction = this.#fraction(frame.timestamp) + const active = frame.timestamp === activeFrame + return html`
-
${renderNetworkRequestDetail(req)}
+ + ` + })}
` } - #renderPlayhead(): TemplateResult { + // Bottom scrub bar: full-width line, action tick marks, draggable knob. + #renderScrubBar(): TemplateResult { const fraction = Math.min(1, Math.max(0, this.currentMs / this.#duration)) - // Anchored at the gutter and inset on the right so it tracks the same lane - // coordinates as the action/network markers. - return html`
` + return html` +
+
+ ${this.#sortedCommands.map((command) => { + const tickFraction = this.#fraction(command.timestamp ?? 0) + return html`
` + })} +
+
+ ` } render() { return html` - ${this.#renderControls()} ${this.#renderFilmstrip()}
- ${this.#renderActionsTrack()} ${this.#renderNetworkTrack()} - ${this.#renderTrack('Console', nothing)} ${this.#renderPlayhead()} + ${this.#renderGridlines()} ${this.#renderRulerLabels()} + ${this.#renderThumbTrack()} ${this.#renderScrubBar()}
- ${this.#renderNetworkDrawer()} ` } } diff --git a/packages/app/src/components/workbench.ts b/packages/app/src/components/workbench.ts index 5b87cd9d..0bb9f243 100644 --- a/packages/app/src/components/workbench.ts +++ b/packages/app/src/components/workbench.ts @@ -6,10 +6,11 @@ import { consume } from '@lit/context' import { DragController, Direction } from '../utils/DragController.js' import { consoleLogContext, + metadataContext, networkRequestContext, baselineContext } from '../controller/context.js' -import type { PreservedAttempt } from '@wdio/devtools-shared' +import type { Metadata, PreservedAttempt } from '@wdio/devtools-shared' import '~icons/mdi/arrow-collapse-down.js' import '~icons/mdi/arrow-collapse-up.js' @@ -26,23 +27,37 @@ import './workbench/network.js' import './workbench/compare.js' import './browser/snapshot.js' import './browser/trace-timeline.js' +import './browser/trace-player-controls.js' import { + BROWSER_BACKDROP_GRADIENT, + HEADER_HEIGHT, MIN_WORKBENCH_HEIGHT, MIN_METATAB_WIDTH, ACTIONS_DEFAULT_WIDTH, BROWSER_HEIGHT_RATIO, - RERENDER_TIMEOUT + PLAYER_CONTROLS_HEIGHT, + PLAYER_DOCK_DEFAULT_HEIGHT, + PLAYER_DOCK_MIN_HEIGHT, + PLAYER_SNAPSHOT_WIDTH_RATIO, + RERENDER_TIMEOUT, + TRACE_TIMELINE_MIN_HEIGHT, + TRACE_TIMELINE_DEFAULT_HEIGHT } from '../controller/constants.js' const COMPONENT = 'wdio-devtools-workbench' + +/** Pixel value from a DragController position string (`flex-basis: 123px`). */ +function basisPx(position: string): number | undefined { + const value = parseFloat(position.split(':')[1] ?? '') + return Number.isFinite(value) ? value : undefined +} @customElement(COMPONENT) export class DevtoolsWorkbench extends Element { #toolbarCollapsed = localStorage.getItem('toolbar') === 'true' #workbenchSidebarCollapsed = localStorage.getItem('workbenchSidebar') === 'true' - // Trace-player mode (`pnpm show-trace`): hide the Metadata tab and swap the - // workbench tabs for the timeline player. + // Trace-player mode: full workbench plus the timeline strip and controls bar. @property({ type: Boolean }) playerMode = false @@ -58,6 +73,10 @@ export class DevtoolsWorkbench extends Element { @state() baselines: Map | undefined = undefined + @consume({ context: metadataContext, subscribe: true }) + @state() + metadata: Metadata | undefined = undefined + static styles = [ ...Element.styles, css` @@ -99,6 +118,54 @@ export class DevtoolsWorkbench extends Element { direction: Direction.horizontal }) + #dragTimeline = new DragController(this, { + localStorageKey: 'traceTimelineHeight', + minPosition: TRACE_TIMELINE_MIN_HEIGHT, + maxPosition: () => window.innerHeight * 0.4, + initialPosition: TRACE_TIMELINE_DEFAULT_HEIGHT, + getContainerEl: () => this.#getVerticalWindow(), + direction: Direction.vertical + }) + + // Player-mode pane height; own storage key so it never disturbs the live split. + // The live max bound keeps the handle (and pane) inside the current budget. + #dragVerticalPlayer = new DragController(this, { + localStorageKey: 'playerPaneHeight', + minPosition: MIN_WORKBENCH_HEIGHT, + maxPosition: () => this.#playerPaneBudget(), + initialPosition: Math.max( + MIN_WORKBENCH_HEIGHT, + window.innerHeight - + HEADER_HEIGHT - + PLAYER_CONTROLS_HEIGHT - + TRACE_TIMELINE_DEFAULT_HEIGHT - + PLAYER_DOCK_DEFAULT_HEIGHT + ), + getContainerEl: () => this.#getVerticalWindow(), + direction: Direction.vertical + }) + + // Player snapshot keeps the recorded viewport's shape, slightly narrowed. + #playerAspectRatio(): string { + const viewport = this.metadata?.viewport + const width = Math.round( + (viewport?.width || 1280) * PLAYER_SNAPSHOT_WIDTH_RATIO + ) + return `${width} / ${viewport?.height || 800}` + } + + // Space left for the snapshot pane once the fixed rows and dock minimum eat theirs. + #playerPaneBudget(): number { + return Math.max( + MIN_WORKBENCH_HEIGHT, + window.innerHeight - + HEADER_HEIGHT - + PLAYER_CONTROLS_HEIGHT - + PLAYER_DOCK_MIN_HEIGHT - + this.#timelinePaneHeight() + ) + } + async #getHorizontalWindow() { await this.updateComplete return this.horizontalResizerWindow as Element @@ -144,13 +211,34 @@ export class DevtoolsWorkbench extends Element { if (this.#toolbarCollapsed) { return '' } - const m = this.#dragVertical.getPosition().match(/(\d+(?:\.\d+)?)px/) - const raw = m ? parseFloat(m[1]) : window.innerHeight * BROWSER_HEIGHT_RATIO + if (this.playerMode) { + // Snapshot pane dominates; the CSS clamp keeps the dock minimum in view. + // Literal getPosition() basis lets adjustPosition sync slider ↔ clamped height. + const maxHeight = `calc(100vh - ${ + HEADER_HEIGHT + PLAYER_CONTROLS_HEIGHT + PLAYER_DOCK_MIN_HEIGHT + }px - ${this.#timelinePaneHeight()}px)` + return `flex-grow:0; flex-shrink:0; ${this.#dragVerticalPlayer.getPosition()}; max-height:${maxHeight}; min-height:${MIN_WORKBENCH_HEIGHT}px;` + } + const raw = + basisPx(this.#dragVertical.getPosition()) ?? + window.innerHeight * BROWSER_HEIGHT_RATIO const capped = Math.min(raw, window.innerHeight * 0.7) const paneHeight = Math.max(MIN_WORKBENCH_HEIGHT, capped) return `flex:0 0 ${paneHeight}px; height:${paneHeight}px; max-height:70vh; min-height:0;` } + #timelinePaneHeight(): number { + const raw = + basisPx(this.#dragTimeline.getPosition()) ?? TRACE_TIMELINE_DEFAULT_HEIGHT + const capped = Math.min(raw, window.innerHeight * 0.4) + return Math.max(TRACE_TIMELINE_MIN_HEIGHT, capped) + } + + #computeTimelinePaneStyle(): string { + const paneHeight = this.#timelinePaneHeight() + return `flex:0 0 ${paneHeight}px; height:${paneHeight}px; max-height:40vh; min-height:0;` + } + #computeSidebarStyle(): string { if (this.#workbenchSidebarCollapsed) { return 'width:0; flex:0 0 0; overflow:hidden;' @@ -173,11 +261,9 @@ export class DevtoolsWorkbench extends Element { - ${this.playerMode - ? nothing - : html` - - `} + + +