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
85 changes: 43 additions & 42 deletions web/src/lib/components/LabShell.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
labId: LabId;
/** Left-hand description panel (becomes a slide-in drawer under 768px). */
info: Snippet;
/** Extra playback-bar controls, rendered between the iteration readout and the Speed slider. */
/** Extra controls-bar controls, rendered between the iteration readout and the Speed slider. */
controls?: Snippet<[LabApi]>;
/** Called once the engine is constructed and the frame loop is running. */
onReady?: (api: LabApi) => void;
Expand Down Expand Up @@ -254,6 +254,40 @@
{@render info()}
</aside>

<div class="playback-bar">
{#if playback}
<button onclick={onReset} title="Reset to iteration 0">↺</button>
<button onclick={onStepBack} title="Step back">◀</button>
<button
onclick={onTogglePlay}
title={api.snapshot.playing ? 'Pause' : 'Play'}
>{api.snapshot.playing ? '⏸' : '▶'}</button>
<button onclick={onStepForward} title="Step forward">▶▶</button>

<span class="iteration">
{api.snapshot.iteration} / {api.snapshot.max_iterations}
<span class="sub">{api.snapshot.sub_progress.toFixed(2)}</span>
</span>
{/if}

{@render controls?.(api)}

{#if playback}
<label class="speed">
Speed
<input
type="range"
min="0.25"
max="360"
step="0.25"
value={api.snapshot.speed}
oninput={(e) => onSpeedInput(Number((e.target as HTMLInputElement).value))}
/>
<span class="value">{api.snapshot.speed.toFixed(1)}</span>
</label>
{/if}
</div>

<div class="canvas-wrap">
<canvas
id="viz-canvas-{labId}"
Expand Down Expand Up @@ -291,49 +325,16 @@
{/if}
</div>

<footer class="playback-bar">
{#if playback}
<button onclick={onReset} title="Reset to iteration 0">↺</button>
<button onclick={onStepBack} title="Step back">◀</button>
<button
onclick={onTogglePlay}
title={api.snapshot.playing ? 'Pause' : 'Play'}
>{api.snapshot.playing ? '⏸' : '▶'}</button>
<button onclick={onStepForward} title="Step forward">▶▶</button>

<span class="iteration">
{api.snapshot.iteration} / {api.snapshot.max_iterations}
<span class="sub">{api.snapshot.sub_progress.toFixed(2)}</span>
</span>
{/if}

{@render controls?.(api)}

{#if playback}
<label class="speed">
Speed
<input
type="range"
min="0.25"
max="360"
step="0.25"
value={api.snapshot.speed}
oninput={(e) => onSpeedInput(Number((e.target as HTMLInputElement).value))}
/>
<span class="value">{api.snapshot.speed.toFixed(1)}</span>
</label>
{/if}
</footer>
</div>

<style>
.layout {
display: grid;
grid-template-columns: 320px 1fr;
grid-template-rows: 1fr auto;
grid-template-rows: auto 1fr;
grid-template-areas:
"info canvas"
"info bar";
"info bar"
"info canvas";
height: 100%; /* the app grid owns 100dvh; we fill our row */
min-height: 0;
}
Expand Down Expand Up @@ -465,7 +466,7 @@
.playback-bar {
grid-area: bar;
background: var(--bar);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 0.5rem 1rem;
display: flex;
align-items: center;
Expand Down Expand Up @@ -508,14 +509,14 @@

/* ===== Mobile ===== */
@media (max-width: 768px) {
/* Canvas + playback bar fill the viewport. Info panel becomes a
/* Controls bar + canvas fill the viewport. Info panel becomes a
slide-in drawer triggered by the info-toggle button. */
.layout {
grid-template-columns: 1fr;
grid-template-rows: 1fr auto;
grid-template-rows: auto 1fr;
grid-template-areas:
"canvas"
"bar";
"bar"
"canvas";
}
.info {
position: fixed;
Expand Down
8 changes: 8 additions & 0 deletions web/src/lib/components/__tests__/LabShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ describe('LabShell extension points', () => {
).toBeTruthy();
});

it('renders the controls bar before the canvas, so tab order matches the top-of-page layout', async () => {
const { container } = render(LabShellHost);
await vi.waitFor(() => expect(container.querySelector('.playback-bar')).toBeTruthy());
const bar = container.querySelector('.playback-bar')!;
const wrap = container.querySelector('.canvas-wrap')!;
expect(bar.compareDocumentPosition(wrap) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});

it('omits the overlay div entirely when no overlay snippet is passed', async () => {
const { container } = render(LabShellHost);
await vi.waitFor(() => expect(container.querySelector('.canvas-wrap')).toBeTruthy());
Expand Down
Loading