Split the shell into topbar, pool, stage, timeline and params panes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-29 12:25:46 -04:00
parent ddabfbeaa8
commit 179770d7d4
24 changed files with 2138 additions and 611 deletions

View file

@ -185,33 +185,90 @@ const PROBE = `(() => {
w: c.width, h: c.height, drawn, tones: [...tones].length, toneSet: [...tones],
cx: drawn ? cx / drawn : null, cy: drawn ? cy / drawn : null,
hash: h >>> 0,
frame: document.querySelector('.readout span')?.textContent ?? '',
selectedClip: [...document.querySelectorAll('.transport .row button')]
.filter((b) => b.classList.contains('on')).map((b) => b.textContent),
frame: document.querySelector('.time .pane-head .dim')?.textContent ?? '',
// What the top bar says about the document. The media pool holds the OPEN
// document's library now and no longer names documents at all, so the status
// line is the page's own answer to "what am I looking at".
doc: document.querySelector('.top .status')?.textContent ?? '',
};
})()`;
// The scrubber is the timeline's ruler — there is no range input any more — so a
// seek is a pointer event at the frame's own column. The ruler maps x to
// floor(x / width * frames), and a mark that names one frame sits at its centre,
// so (f + 0.5) lands inside f and nowhere near its neighbours.
//
// The frame count comes off the readout rather than being passed in: the caller
// knows which frame it wants, not how long the clip it is looking at happens to
// be, and reading it here is one place instead of every call site.
const SEEK = (f) => `(() => {
const el = document.querySelector('input.scrub');
// React installs its own value setter on the element, so assigning .value
// directly updates the DOM and not React's idea of it, and onChange never
// fires. The prototype-level setter plus a bubbling 'input' event is what
// React's synthetic onChange actually listens for.
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
set.call(el, '${f}');
el.dispatchEvent(new Event('input', { bubbles: true }));
return el.value;
const read = document.querySelector('.time .pane-head .dim').textContent;
// Split, not a regex: this is inside a template literal, where an escaped
// slash collapses to a bare one and the two together open a line comment that
// eats the rest of the statement. The readout is "12 / 229" and nothing else.
const frames = Number(read.split('/')[1]);
if (!frames) return 'no readout';
const ruler = document.querySelector('.tl-ruler');
const box = ruler.getBoundingClientRect();
ruler.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, pointerId: 1,
clientX: box.left + (${f} + 0.5) * box.width / frames,
clientY: box.top + box.height / 2,
}));
ruler.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1 }));
return document.querySelector('.time .pane-head .dim').textContent;
})()`;
// Everything the page has to say about loading, saving and opening. Read off the
// page rather than out of app-db, for the same reason the playhead is: what the
// page SHOWS is what a person would check.
const STATUS = `[...document.querySelectorAll('.load-status')].map((d) => d.textContent).join(' | ')`;
const STATUS = `[...document.querySelectorAll('.top .status, .pool .pane-body > .dim')]
.map((d) => d.textContent).join(' | ')`;
// Any button on the page, by its own label. The controls are spread across five
// panes now — the transport is in the timeline's header, save and open are in the
// top bar, the clips are rows in the media pool — and a helper that knew which
// pane each one lived in would be a second copy of the layout.
//
// `firstChild` is the label: a media-pool row has a second line in a child span,
// so matching on textContent would never find "take".
const CLICK = (label) => `(() => {
const b = [...document.querySelectorAll('.transport button')]
.find((b) => b.textContent.trim() === ${JSON.stringify(label)});
if (!b) return false;
const b = [...document.querySelectorAll('button')]
.find((b) => (b.firstChild?.textContent ?? '').trim() === ${JSON.stringify(label)});
if (!b || b.disabled) return false;
b.click();
return true;
})()`;
// Documents are behind File -> Open now, not in the media pool: the pool holds
// the OPEN document's library, and a whole project is not a thing you put on a
// stage. So reaching a built-in scene or a saved project is two clicks with a
// turn of the event loop between them, as it is for anybody using the app.
const MENU_OPEN = `(() => {
if (document.querySelector('.menu')) return true;
const b = [...document.querySelectorAll('button')]
.find((b) => b.textContent.trim() === 'open \u25be');
if (!b || b.disabled) return false;
b.click();
return true;
})()`;
// Scoped to `.example`, and it has to be: the local server accumulates a saved
// project called "take" on every run of this suite, so an unscoped match by
// label picks whichever of those sorted first and the fixture is never reached.
const MENU_PICK = (label) => `(() => {
const b = [...document.querySelectorAll('.menu .menu-item.example')]
.find((b) => (b.firstChild?.textContent ?? '').trim() === ${JSON.stringify(label)});
if (!b || b.disabled) return false;
b.click();
return true;
})()`;
// The first row under PROJECTS, which the server orders by -updated: this is
// what "open" meant before there was a list to pick from.
const MENU_PICK_NEWEST = `(() => {
const b = document.querySelector('.menu .menu-item:not(.example)');
if (!b || b.disabled) return false;
b.click();
return true;
})()`;
@ -223,24 +280,44 @@ async function main() {
mkdirSync(OUT, { recursive: true });
const page = await connect();
const fromMenu = async (what) => {
if (!(await page.eval(MENU_OPEN))) return false;
await sleep(150);
return page.eval(what);
};
try {
// Mounted, and painting. Polled rather than waited on a fixed delay: the
// canvas :ref fires after the loop starts, so there genuinely is a window in
// which the page is up and the canvas is blank.
// Mounted. Polled rather than waited on a fixed delay: the canvas :ref fires
// after the loop starts, so there genuinely is a window in which the page is
// up and there is nothing to measure.
//
// It is NOT polled for pixels any more. The app opens on a blank document —
// no clip is loaded until one is asked for — so "the canvas has drawn
// something" is now a thing this suite makes happen rather than a thing it
// waits for.
let probe = null;
for (let i = 0; i < 100; i++) {
probe = await page.eval(PROBE);
if (probe && probe.drawn > 0) break;
if (probe && probe.w) break;
await sleep(100);
}
if (!probe) throw new Error('no canvas.stage on the page — ' +
(page.logs.slice(0, 3).join(' | ') || 'is `shadow-cljs watch app` running?'));
console.log(`\ncanvas ${probe.w}x${probe.h}, clip ${JSON.stringify(probe.selectedClip)}`);
console.log(`\ncanvas ${probe.w}x${probe.h}, ${probe.doc}`);
check(probe.selectedClip.includes('take'), 'the take is the clip that opens');
check(probe.drawn === 0 && /new document/.test(probe.doc),
'the app opens on a blank document',
`${probe.drawn} px drawn — ${probe.doc}`);
check(probe.w === 320 && probe.h === 200, 'the canvas is the stage size',
`${probe.w}x${probe.h}`);
// Everything below is about the frozen take, so open it out of the pool.
check(await fromMenu(MENU_PICK('take')), 'the take opens from the open menu');
for (let i = 0; i < 100; i++) {
probe = await page.eval(PROBE);
if (probe.drawn > 0) break;
await sleep(100);
}
check(probe.drawn > 200, 'the first frame is not blank', `${probe.drawn} px drawn`);
// --- it is a mouth: two tones, one inside the other ---
@ -283,7 +360,7 @@ async function main() {
'as filmed, the head carries the mouth across the stage',
`centroid x spans ${(Math.max(...xs) - Math.min(...xs)).toFixed(1)} px`);
check(await page.eval(CLICK('locked')), 'the locked take is selectable');
check(await fromMenu(MENU_PICK('locked')), 'the locked take is selectable');
await sleep(200);
const locked = [];
for (const f of [0, 40, 80, 120, 160, 200]) {
@ -304,7 +381,7 @@ async function main() {
'and it is still a performance, not a still frame');
// --- it PLAYS, against the audio clock ---
check(await page.eval(CLICK('take')), 'back to the take');
check(await fromMenu(MENU_PICK('take')), 'back to the take');
await sleep(150);
await page.eval(SEEK(0));
await sleep(150);
@ -312,7 +389,7 @@ async function main() {
const during = [];
for (let i = 0; i < 8; i++) { await sleep(180); during.push(await page.eval(PROBE)); }
await page.eval(CLICK('pause'));
// The readout is "frame 12 / 229", so the first run of digits is the
// The readout is "12 / 229", so the first run of digits is the
// playhead. Parsed rather than reached for in app-db on purpose: what the
// page SHOWS is what a person would check, and the readout agreeing with the
// picture is half of what the transport is for.
@ -351,7 +428,7 @@ async function main() {
}
const FRAMES = [0, 10, 28, 80, 160];
check(await page.eval(CLICK('take')), 'back to the take, for the round trip');
check(await fromMenu(MENU_PICK('take')), 'back to the take, for the round trip');
await sleep(150);
const sent = await sample(FRAMES);
@ -377,7 +454,7 @@ async function main() {
check(resaved !== null, 'saving an unchanged document writes nothing',
resaved ?? (await page.eval(STATUS)));
check(await page.eval(CLICK('open')), 'open is clickable');
check(await fromMenu(MENU_PICK_NEWEST), 'open is clickable');
const opened = await statusMatching(/opened /);
check(opened !== null, 'the project opens', opened ?? (await page.eval(STATUS)));
@ -392,8 +469,8 @@ async function main() {
'and the open mouth still has an interior on the far side');
// The reopened clip is not one of the built-ins: this is the document that came
// back from the server, not the one that was in the page all along.
check(!back[0].selectedClip.includes('take'), 'the picture is the reopened document',
JSON.stringify(back[0].selectedClip));
check(/opened /.test(back[0].doc), 'the picture is the reopened document',
back[0].doc);
// Paint through the visible controls. This exercises the SVG pointer path,
// the authored node, the raster preview and the document round trip.
@ -404,10 +481,20 @@ async function main() {
const d = c.getContext('2d').getImageData(25, 20, 1, 1).data;
return [...d];
})()`);
const painted = await page.eval(`(() => {
const button = [...document.querySelectorAll('.paint-tools button')]
.find(b => b.textContent === 'new polygon');
// Arming the tool and clicking the vertices are two evals with a turn of the
// event loop between them, and they have to be: picking the tool dispatches a
// re-frame event, which is queued rather than applied, so a pointerdown in the
// same synchronous block arrives while the tool is still unset and is dropped.
// A person cannot click twice inside one microtask; a test should not either.
const armed = await page.eval(`(() => {
const button = [...document.querySelectorAll('.palette-bar button')]
.find(b => b.textContent === 'polygon');
if (!button) return false;
button.click();
return true;
})()`);
await sleep(100);
const painted = armed && await page.eval(`(() => {
const svg = document.querySelector('.paint-overlay');
const box = svg.getBoundingClientRect();
for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) {
@ -420,8 +507,8 @@ async function main() {
})()`);
await sleep(100);
const finished = await page.eval(`(() => {
const button = [...document.querySelectorAll('.paint-tools button')]
.find(b => b.textContent === 'finish shape');
const button = [...document.querySelectorAll('.palette-bar button')]
.find(b => b.textContent === 'finish');
if (!button || button.disabled) return false;
button.click();
return true;
@ -438,8 +525,8 @@ async function main() {
await page.eval(SEEK(f));
await sleep(100);
check(await page.eval(`(() => {
const button = [...document.querySelectorAll('.paint-tools button')]
.find(b => b.textContent === 'new drawing key');
const button = [...document.querySelectorAll('.params button')]
.find(b => b.textContent === 'drawing key here');
if (!button || button.disabled) return false;
button.click();
return true;
@ -449,7 +536,7 @@ async function main() {
await page.eval(SEEK(8));
await sleep(100);
const tweenSelected = await page.eval(`(() => {
const label = [...document.querySelectorAll('.paint-tools label')]
const label = [...document.querySelectorAll('.params label')]
.find(el => el.textContent.startsWith('key 8 → 16'));
const select = label?.querySelector('select');
if (!select) return false;
@ -459,14 +546,14 @@ async function main() {
})()`);
await sleep(100);
check(tweenSelected && await page.eval(`(() => {
const label = [...document.querySelectorAll('.paint-tools label')]
const label = [...document.querySelectorAll('.params label')]
.find(el => el.textContent.startsWith('key 8 → 16'));
return label?.querySelector('select')?.value === 'linear';
})()`), 'the second drawing gap can be set to tween');
check(await page.eval(CLICK('save')), 'the painted document can be saved');
check((await statusMatching(/saved r\d+ · \d+ leaves/)) !== null,
'the painted shape is saved', await page.eval(STATUS));
check(await page.eval(CLICK('open')), 'the painted document can be reopened');
check(await fromMenu(MENU_PICK_NEWEST), 'the painted document can be reopened');
check((await statusMatching(/opened /)) !== null,
'the painted shape is reopened', await page.eval(STATUS));
await sleep(150);
@ -479,7 +566,7 @@ async function main() {
await page.eval(SEEK(8));
await sleep(100);
check(await page.eval(`(() => {
const label = [...document.querySelectorAll('.paint-tools label')]
const label = [...document.querySelectorAll('.params label')]
.find(el => el.textContent.startsWith('key 8 → 16'));
return label?.querySelector('select')?.value === 'linear';
})()`), 'the per-gap tween setting survives the project round trip');
@ -489,11 +576,11 @@ async function main() {
const stageSource = await page.eval(`fetch('/api/projects/4379f900-bdd2-409b-acf6-32081f8ce01f')
.then(r => r.ok)`);
if (stageSource) {
check(await page.eval(CLICK('stage 8625')), 'the 8625 stage loads');
check(await fromMenu(MENU_PICK('8625 stage study')), 'the 8625 stage loads');
const stageLoaded = await statusMatching(/loaded 8625 stage study/, 160);
check(stageLoaded !== null, 'the 8625 stage is ready', stageLoaded ?? (await page.eval(STATUS)));
const eyeSelected = await page.eval(`(() => {
const select = document.querySelector('.controls select');
const select = document.querySelector('.params .section:last-child select');
// Settings belong to tracked features shared by the stage placements.
const option = [...select.options]
.find(o => o.textContent.trim().endsWith('feature · face-1/eye-r'));
@ -505,8 +592,8 @@ async function main() {
check(eyeSelected, 'a stage instance exposes its tracked right eye');
await sleep(100);
const irisSlider = await page.eval(`(() => {
const row = [...document.querySelectorAll('.control-row')]
.find(row => row.querySelector('span')?.textContent === 'iris-size');
const row = [...document.querySelectorAll('.params .knob')]
.find(row => row.querySelector('.name')?.textContent === 'iris-size');
if (!row) return null;
// Scrolled into view FIRST, because the click below is dispatched at
// viewport coordinates: a control panel that has grown past the fold
@ -520,7 +607,7 @@ async function main() {
check(irisSlider !== null, 'the stage eye has an iris-size slider');
if (irisSlider) {
check(await page.eval(CLICK('play')), 'the stage starts playing');
const startFrame = await page.eval(`Number(document.querySelector('.readout span').textContent.match(/\\d+/)[0])`);
const startFrame = await page.eval(`Number(document.querySelector('.time .pane-head .dim').textContent.match(/\\d+/)[0])`);
await page.send('Input.dispatchMouseEvent', {
type: 'mousePressed', x: irisSlider.x, y: irisSlider.y, button: 'left', clickCount: 1,
});
@ -532,12 +619,12 @@ async function main() {
check(preview !== null, 'the slider updates the stage preview', preview ?? debug);
check(debug.includes(':face-1/eye-r') && debug.includes('tier 1 only'),
'the panel reports the affected feature and tier', debug);
const endFrame = await page.eval(`Number(document.querySelector('.readout span').textContent.match(/\\d+/)[0])`);
const endFrame = await page.eval(`Number(document.querySelector('.time .pane-head .dim').textContent.match(/\\d+/)[0])`);
check(endFrame > startFrame, 'playback continues during tuning', `${startFrame} -> ${endFrame}`);
await page.eval(CLICK('pause'));
}
const subjectSelected = await page.eval(`(() => {
const select = document.querySelector('.controls select');
const select = document.querySelector('.params .section:last-child select');
const option = [...select.options]
.find(o => o.textContent.includes('subject · face-1'));
if (!option) return false;
@ -549,8 +636,8 @@ async function main() {
if (subjectSelected) {
await sleep(100);
const anchorSlider = await page.eval(`(() => {
const row = [...document.querySelectorAll('.control-row')]
.find(row => row.querySelector('span')?.textContent === 'anchor-avg');
const row = [...document.querySelectorAll('.params .knob')]
.find(row => row.querySelector('.name')?.textContent === 'anchor-avg');
if (!row) return null;
// Scrolled into view FIRST, because the click below is dispatched at
// viewport coordinates: a control panel that has grown past the fold
@ -586,7 +673,7 @@ async function main() {
}
}
const teethSelected = await page.eval(`(() => {
const select = document.querySelector('.controls select');
const select = document.querySelector('.params .section:last-child select');
const option = [...select.options]
.find(o => o.textContent.trim().endsWith('feature · face-1/teeth'));
if (!option) return false;
@ -598,8 +685,8 @@ async function main() {
if (teethSelected) {
await sleep(100);
const slider = await page.eval(`(() => {
const row = [...document.querySelectorAll('.control-row')]
.find(row => row.querySelector('span')?.textContent === 'cavity-erode');
const row = [...document.querySelectorAll('.params .knob')]
.find(row => row.querySelector('.name')?.textContent === 'cavity-erode');
if (!row) return null;
// Scrolled into view FIRST, because the click below is dispatched at
// viewport coordinates: a control panel that has grown past the fold
@ -623,10 +710,10 @@ async function main() {
let debug = '';
for (let i = 0; i < 160; i++) {
debug = await page.eval(`document.querySelector('.regeneration-debug')?.textContent ?? ''`);
if (debug.includes('dirty features: [:face-1/teeth]')) break;
if (debug.includes('dirty: [:face-1/teeth]')) break;
await sleep(250);
}
check(debug.includes('dirty features: [:face-1/teeth]'),
check(debug.includes('dirty: [:face-1/teeth]'),
'the pixel setting invalidates only teeth', debug);
const preview = await statusMatching(/preview · unsaved/, 160);
check(preview !== null, 'the teeth edit finishes previewing',
@ -650,7 +737,19 @@ async function main() {
nodeId: root.nodeId, selector: 'input[type=file]',
});
await page.send('DOM.setFileInputFiles', { files: [video], nodeId });
const extracted = await statusMatching(/video extracted/, 160);
// Waited for in the MEDIA POOL rather than in the status line. Dropping or
// choosing a video now runs straight on into detection, so "video
// extracted" is a state the app passes through in one turn of the event
// loop and not one a poller can be relied on to catch. The row appearing in
// the pool is the durable evidence, and it is what a person would look at.
let extracted = null;
for (let i = 0; i < 160 && extracted === null; i++) {
await sleep(250);
if (await page.eval(`[...document.querySelectorAll('.pool-item')]
.some((b) => (b.firstChild?.textContent ?? '').trim() === 'browser-upload.mp4')`)) {
extracted = 'listed in the media pool';
}
}
check(extracted !== null, 'the page uploads and extracts a video',
extracted ?? (await page.eval(STATUS)));
const uploadedFootage = await page.eval(`(async () => {