mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
Studio's panels become cards on a dark shell, and each panel's tabs take the "Current" design: the open tab is a fill in the card's colour that slides to the tab you pick, only the open tab shows its icon and close glyph, and a strip that is too narrow scrolls with a soft edge instead of hiding tabs behind a menu. This replaces the pill, underline and close treatment that was on this PR before. ## Before   ## After   Switching tabs (the fill slides in about 140 ms and the new icon fades in over 80 ms; the panel body switches at once): https://github.com/user-attachments/assets/8d359ada-c9e1-4425-9c95-61d5d4f5271b ### Every label whole, and the tabs use the whole strip (2x) An earlier head clipped the left strip's fourth tab next to empty strip space. That space was a 72 px slot (plus 4 px) held on every strip for the strip actions, even on strips whose panel is not focused and so draws no actions. Dockview sizes that slot by its content; this PR had pinned it. Now the slot takes room only where the actions are drawn, so an unfocused strip gives its tabs the whole width. Tab padding is 6 px with no gap between tabs, so the tabs also fit next to the actions. Labels, icons and close glyphs sit on the tab's centre in every strip. Left strip at 1440 x 900, tabs width against the room before the action slot: | | macOS | Linux (these captures) | |---|---|---| | Earlier head | 322 in 280, clipped | 312 in 280, clipped | | Now, panel unfocused | 278 in 358 | 268 in 358 | | Now, panel focused (actions shown) | 278 in 282 | 268 in 282 |    At 1280 x 800 the left column is narrower, and the pinned slot clipped Catalog next to the empty space. Room for the tabs there went from 241 px to 317 px:   Captured at the PR head (5fd6c7bd) from a fixture project (the `warm-grain` registry example) in headless Chrome on Linux, 2x, each state loaded fresh so Studio builds its default layout for that window. Before is `main`; the strip Befores use the earlier heads' `dock.css` (8079c635at 1440,8ed7ab69at 1280) on the same build. The macOS column was measured live in Chrome on macOS. The paper theme only re-themes the timeline today, so the panels look the same in both themes. ## What changed - **Cards on the shell.** Each dock group is a card with the only 1 px border (radius 10) and a 6 px gutter to its neighbours. The preview and timeline no longer draw a second rounded border inside it; the preview stage is an inset well. Dockview's tab dividers and separators are gone. - **Tabs.** Tabs use the whole strip unless the panel shows its actions. Every label is whole at the default 1440 x 900 layout (6 px side padding, no gap between tabs), and labels and icons are centred in the tab. The open tab is a fill with rounded top corners, flush with the card body. Only the open tab shows its type icon and close glyph (the glyph appears on hover or keyboard focus, and its space is kept so the label never moves). Other tabs are labels. - **One sliding fill per strip.** One element slides (transform and width, 140 ms ease-out) under the open tab. Reduced motion turns the slide and the icon fade off. Hover is instant. - **Strip actions** (panel menu, maximise or restore, close group) sit on the active group only, at half opacity while the pointer is in the panel and full on hover or focus. They take room only on the strip that draws them, so the other strips give their tabs the whole width. The panel menu lists that side's panels as checkboxes, plus Reset layout. - **Narrow strips** scroll sideways with a 12 px fade on the clipped edge. Dockview's overflow dropdown is off. - Tokens: `--panel-shell-bg`, `--panel-card-bg`, `--panel-card-border`, `--panel-strip-bg`, `--panel-tab-bg-hover`, with contrast pairs registered. ## Timeline viewport gate The first head of this rebuild (b20ff32a4) cost the timeline gate one frame on CI: interaction p95 33 ms on every run (first attempt 100 ms on three runs), against main's 20 ms. Main-thread traces were identical; the extra cost was compositing, from a fade mask on every tab strip. Masking only a strip that is actually clipped brought it back: | Head | Interaction p95 per run (ms) | Frame p95 (ms) | Runs passing | |---|---|---|---| | main | 20, 20, 19, 20, 18 | 34 | 5/5 | |b20ff32a4(rerun) | 33, 33, 33, 33, 33 | 33 | 5/5 | |41302773| 18, 20, 19, 20, 17 | 33 | 5/5 | |eefaa839| 18, 20, 18, 18, 20 | 17 | 5/5 | |af4f2b9b(tab spacing), first attempt | 18, 79, 18, 116, 19 | 100 | 3/5 | |af4f2b9b, rerun | 18, 16, 20, 18, 18 | 33 | 5/5 | |8ed7ab69| 17, 16, 19, 24, 20 | 33 | 5/5 | |6a5edd89| 33, 33, 33, 33, 33 | 17 | 5/5 | From about 14:30Z `main` itself reads 33 ms on every run (three consecutive `main` CI runs), and CI tests this PR merged with the current `main`, so the6a5edd89row matches `main`'s level at that time. Theaf4f2b9bfirst attempt had three runs at main's level and two single-run spikes; the same commit's rerun passed 5/5, and the change is tab spacing only. ## Tests Each rule that could regress without anyone noticing has a test, and each test was proved by breaking the code once: | Rule | Test | Break | Result | |---|---|---|---| | Icon and close glyph on the open tab only | `DockTabs.test.tsx` (dock.css applied, computed display) | delete the hide rule | 2 red | | Actions on the active group only | `DockTabs.test.tsx` | drop the active-group check | 1 red | | One fill per strip, following the open tab | `DockTabs.test.tsx` | fill stays at 0 / a second fill | 1 red each | | One border per surface | `dockTabs.contract.test.ts` | add a strip border | 1 red | | Only the slide and icon fade animate | `dockTabs.contract.test.ts` | add a hover transition | 1 red | | Reduced motion turns them off | `dockTabs.contract.test.ts` | remove the reduced-motion rule | 1 red | | Label and icon centred in the tab | `dockTabs.contract.test.ts` | drop the full-height rule | 1 red | | Every tab label whole at 1440 x 900 on each strip while its panel is focused (the least room it gets), with 10 px for macOS's wider system font (CI renders a Linux fallback) | `scripts/studio-runtime-smoke.mjs` (CI "Studio: load smoke", real Chrome) | the "Ca" head's spacing; a spacing that fits on Linux but clips on macOS | red on both ("312px of tabs in 280px", "276px of tabs in 280px"); green on main and this head | | Unfocused strips hold no room for actions they do not draw | `scripts/studio-runtime-smoke.mjs` | pin the action slot at 72 px again | red: "3 inactive dock strips hold width for actions they do not draw" | | The shown tab stays whole when its strip narrows (its panel gains focus and the actions appear), comes back narrower than it was before it was hidden, or when a neighbouring tab closes and it moves | `DockTabs.test.tsx` | ignore each of those changes in turn | 1 red each; checked live at 1280 x 800: clicking into the left panel's body used to leave Catalog 37 px hidden, now it is whole | | A strip the user scrolled by hand stays put when something else in the dock changes, when it widens, and through another panel's maximise and restore | `DockTabs.test.tsx` | reveal on every pass; on any width change; after a hidden spell | red each; checked live: a hand scroll of 20 px is still 20 px after a maximise and restore | Run alone, each exits 0: `DockTabs.test.tsx` 5, `dockTabs.contract.test.ts` 5, `Dock.test.tsx` 14, `dockAccessibility.test.ts` 10, `PreviewPane.test.tsx` 1, `Menu.test.tsx` 10, `scripts/contrastRatchet.test.ts` passing. ## Independent review An adversarial review (separate agent, mandate to refute) at41302773,eefaa839,8079c635,af4f2b9band8ed7ab69: **no blockers; one major, fixed.** - MAJOR (af4f2b9b), fixed in8ed7ab69: CI renders Studio's system font stack with a narrower Linux fallback, so the clip check could pass while a Mac clips. The check now keeps 10 px of room for the macOS font, and a spacing that fits on Linux but clips on macOS turns it red. The8ed7ab69review asked whether 10 px holds for every strip, not just the left one it was measured on. Measured macOS minus Linux tab width at 1440 x 900: left 10 px, right 6 px, Preview and Timeline 2 px each, so the left strip is the widest gap and 10 px covers all four. - MINOR, left as is: the room check has no unit test of its own; it runs only in a real browser, and the smoke is its test (red on both broken spacings above). - MINOR: a panel cannot be closed from the focused tab itself by keyboard. The keyboard path is focused tab, Tab to "Panel menu", Enter, uncheck the panel (checked live: closes that one panel; checking it again reopens it). The tab's close glyph is not focusable, the same as dockview's stock tab on main, because a tab cannot hold a focusable button. - MINOR: the open tab is 16 px wider than on the design page (the close glyph's space is kept; see For Miguel). - Resolved: a contrast pair cited stale line numbers. ## UX checklist **Job:** someone editing a video moves between panels (Compositions, Assets, Code, Catalog; Design, Layers, Renders, Variables) many times an hour and needs to see which one is open and reach the next without hunting. **One action:** click the tab of the panel you want. | # | Question | Y/N | Evidence | |---|---|---|---| | 1 | First-time user knows what to do | Y | Tabs keep their labels; the open tab is joined to the body it controls (After captures) | | 2 | Interactive things look interactive | N | Strip actions are hidden until the pointer is in the panel (Miguel's decision; see Traded off) | | 3 | User's words, no internal ids | Y | Titles unchanged; controls named "Close Design", "Panel menu", "Maximize panel", "Close group" | | 4 | Works like other tools | Y | Editor-style tabs with maximise and a panel menu | | 5 | Guidance at the control | N | The three icon buttons have accessible names but no visible tooltip | | 6 | Primary action reachable without reading | Y | The tabs are the strip | | 7 | Exactly one primary action | Y | Tabs; actions are secondary and quiet | | 8 | Counts match data | Y | Titles come from each panel through `Dock.Panel` (`Dock.tsx` setTitle) | | 9 | Excluded items announce themselves | Y | Every label is whole at the default width (the load smoke checks it); a narrowed strip scrolls with an edge fade, and the panel menu lists every panel | | 10 | Long names recoverable | Y | Tabs never truncate; the strip scrolls | | 11 | Keyboard path | Y | Arrow keys move and activate tabs (existing); strip actions are buttons and show on focus | | 12 | Nothing hover-only that must be acted on | N | Same as 2 | | 13 | Refusals say what next | n/a | No refusals here | | 14 | Refusal next to the thing | n/a | | | 15 | Error prevented | n/a | | | 16 | Can leave any state without losing work | Y | Closed panels reopen from Window or the panel menu; Reset layout restores the default | | 17 | Destructive looks different | N | "Close group" uses the same x as a tab's close; it loses no work, and the Window menu reopens the panels | | 18 | Empty state names what is empty | n/a | | | 19 | Feedback within 400 ms | Y | The tab state and icon change in the same frame as the click; the fill arrives in 140 ms | | 20 | Progress on waits | n/a | | | 21 | Hand-built state survives reload | Y | Layout, open tab and maximised group persist per project (existing dock persistence) | **Choice:** four tabs per side, one click each. **The end:** the panel body appears at once and the fill settles under the tab you picked. **Not exercised:** a screen reader pass; touch input. **Traded off:** hover-only strip actions (rows 2 and 12) and scroll-plus-fade instead of an overflow menu when a strip is narrowed are Miguel's decided design. ## For Miguel - **Paper (light) theme:** Studio's paper block only re-themes the timeline today, so the panels are dark in both the Before and After paper shots. Panel paper values are Desktop's to set (design principles, section 7); this PR adds the token names Desktop can override. - **Action slot:** the design review asked for the action slot to be reserved on every strip, so tabs never move when focus changes panel. That reservation is what clipped "Ca" next to empty space, so it now holds room only on the focused panel. The cost: when a strip that is already too narrow gains focus, its actions take 76 px. The strip then scrolls so the shown tab stays whole, and a neighbouring tab can go under the edge fade. At the default 1440 x 900 nothing clips either way. - **Tab spacing:** side padding is 6 px (10 on the design page) and tabs have no gap. The design's 10 px would need 322 px on macOS; the left strip has 282 px beside its actions at 1440. A wider default left column would allow the design padding again. A narrower window scrolls the strip with the edge fade; per the decision there is no overflow chevron. - **Close glyph space:** the open tab keeps its close glyph's space at rest, so it is 16 px wider than on the design page, where the label shifts on hover. - **Not in this PR:** inspector section headers and the focused-card border step (later build steps).
222 lines
8.2 KiB
JavaScript
222 lines
8.2 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { createRequire } from "node:module";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const ROOT = join(import.meta.dirname, "..");
|
|
const PROJECT_ID = "smoke-test";
|
|
export const SMOKE_COMPOSITION_HTML =
|
|
'<!doctype html><html><body><div data-composition-id="root" data-width="1920" ' +
|
|
'data-height="1080" data-duration="1" data-start="0"><div class="clip" ' +
|
|
'data-hf-id="title" data-start="0" data-duration="1">Test</div></div></body></html>';
|
|
const SMOKE_THUMBNAIL_SVG =
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="9"><rect width="16" height="9"/></svg>';
|
|
|
|
function json(body, status = 200) {
|
|
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
|
}
|
|
|
|
function text(body, contentType = "text/plain") {
|
|
return { status: 200, contentType, body };
|
|
}
|
|
|
|
const PROJECT_PATH = `/api/projects/${PROJECT_ID}`;
|
|
const GET_RESPONSES = new Map([
|
|
[
|
|
"/api/projects",
|
|
json({ projects: [{ id: PROJECT_ID, dir: "/tmp/smoke-test", title: "Smoke test" }] }),
|
|
],
|
|
[
|
|
PROJECT_PATH,
|
|
json({
|
|
id: PROJECT_ID,
|
|
dir: "/tmp/smoke-test",
|
|
title: "Smoke test",
|
|
files: ["index.html"],
|
|
compositions: ["index.html"],
|
|
}),
|
|
],
|
|
[`${PROJECT_PATH}/preview`, text(SMOKE_COMPOSITION_HTML, "text/html")],
|
|
[`${PROJECT_PATH}/thumbnail/index.html`, text(SMOKE_THUMBNAIL_SVG, "image/svg+xml")],
|
|
[`${PROJECT_PATH}/renders`, json({ renders: [] })],
|
|
[`${PROJECT_PATH}/lint`, json({ findings: [] })],
|
|
[`${PROJECT_PATH}/selection`, json({ selection: null, updatedAt: null })],
|
|
["/api/registry/blocks", json([])],
|
|
["/api/fonts", json({ fonts: [] })],
|
|
["/api/fonts/google", json({ fonts: [] })],
|
|
["/api/assets/global", json({ assets: [] })],
|
|
// Studio asks this on load so it can warn before Export instead of failing
|
|
// at encode time. A usable encoder is the case this smoke run wants: the
|
|
// interesting assertion is that the shell mounts clean, not that a blocking
|
|
// notice renders. The notice has its own tests.
|
|
["/api/environment/ffmpeg", json({ ok: true })],
|
|
]);
|
|
const MUTATION_RESPONSES = new Map([
|
|
[`${PROJECT_PATH}/selection`, json({ ok: true, selection: null, updatedAt: null })],
|
|
]);
|
|
|
|
function projectFileResponse(pathname) {
|
|
if (!pathname.startsWith(`${PROJECT_PATH}/files/`)) return undefined;
|
|
const filename = decodeURIComponent(pathname.split("/files/")[1] ?? "");
|
|
return json({
|
|
filename,
|
|
content: filename === "index.html" ? SMOKE_COMPOSITION_HTML : "",
|
|
});
|
|
}
|
|
|
|
function projectPreviewResponse(pathname) {
|
|
if (!pathname.startsWith(`${PROJECT_PATH}/preview/`)) return undefined;
|
|
return pathname.endsWith("/.media/manifest.jsonl")
|
|
? text("", "application/x-ndjson")
|
|
: text(SMOKE_COMPOSITION_HTML, "text/html");
|
|
}
|
|
|
|
function gsapAnimationsResponse(pathname) {
|
|
if (!pathname.startsWith(`${PROJECT_PATH}/gsap-animations/`)) return undefined;
|
|
return json({ animations: [], timelineVar: "tl", preamble: "", postamble: "" });
|
|
}
|
|
|
|
function getStudioSmokeResponse(pathname) {
|
|
return (
|
|
GET_RESPONSES.get(pathname) ??
|
|
projectFileResponse(pathname) ??
|
|
projectPreviewResponse(pathname) ??
|
|
gsapAnimationsResponse(pathname)
|
|
);
|
|
}
|
|
|
|
function studioSmokeApiPathResponse(method, pathname) {
|
|
return method === "GET"
|
|
? (getStudioSmokeResponse(pathname) ?? null)
|
|
: (MUTATION_RESPONSES.get(pathname) ?? null);
|
|
}
|
|
|
|
export function studioSmokeApiResponse(method, requestUrl) {
|
|
const { pathname } = new URL(requestUrl);
|
|
return pathname.startsWith("/api/") ? studioSmokeApiPathResponse(method, pathname) : undefined;
|
|
}
|
|
|
|
// CI renders Studio's system font stack with Linux's fallback, which sets the default tab labels
|
|
// about 10px narrower than macOS does; keep that much room so a strip that fits here fits on a Mac.
|
|
const MAC_FONT_ALLOWANCE_PX = 10;
|
|
|
|
/** Runs in the page: the active group's tabs, plus the allowance, must fit before its actions. */
|
|
function clippedStrip(allowance) {
|
|
const strip = document.querySelector(".dv-groupview.dv-active-group .dv-tabs-container");
|
|
if (!strip) return "no active dock strip";
|
|
const actions = strip
|
|
.closest(".dv-tabs-and-actions-container")
|
|
?.querySelector(".dv-right-actions-container");
|
|
const room =
|
|
(actions?.getBoundingClientRect().left ?? Infinity) - strip.getBoundingClientRect().left;
|
|
if (strip.scrollWidth + allowance <= room) return null;
|
|
const labels = [...strip.querySelectorAll(".dv-tab")].map((tab) => tab.textContent);
|
|
return `${labels.join(", ")}: ${strip.scrollWidth}px of tabs in ${Math.floor(room)}px`;
|
|
}
|
|
|
|
/** Every dock strip gives its tabs the whole width it can, and fits them while its group is active. */
|
|
async function dockStripErrors(page) {
|
|
const found = [];
|
|
const reservedSlots = await page.$$eval(
|
|
".dv-groupview.dv-inactive-group .dv-right-actions-container",
|
|
(slots) => slots.filter((slot) => slot.getBoundingClientRect().width > 0).length,
|
|
);
|
|
if (reservedSlots > 0) {
|
|
found.push(`${reservedSlots} inactive dock strips hold width for actions they do not draw`);
|
|
}
|
|
const shownTabs = await page.$$(".dv-tabs-container .dv-active-tab");
|
|
for (const tab of shownTabs) {
|
|
// An active group draws its strip actions, so this is the least room its tabs get.
|
|
await tab.click();
|
|
await page.evaluate(
|
|
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
|
|
);
|
|
const clipped = await page.evaluate(clippedStrip, MAC_FONT_ALLOWANCE_PX);
|
|
if (clipped) {
|
|
found.push(
|
|
`Dock tab strip clips a label at the default layout (${MAC_FONT_ALLOWANCE_PX}px macOS allowance): ${clipped}`,
|
|
);
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
export function isExpectedStudioSmokeError(message) {
|
|
return message.includes("favicon.ico");
|
|
}
|
|
|
|
async function loadPuppeteer() {
|
|
const requireFromProducer = createRequire(join(ROOT, "packages", "producer", "package.json"));
|
|
return requireFromProducer("puppeteer");
|
|
}
|
|
|
|
export async function runStudioRuntimeSmoke(targetUrl) {
|
|
const puppeteer = await loadPuppeteer();
|
|
const browser = await puppeteer.launch({
|
|
headless: "new",
|
|
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
});
|
|
const page = await browser.newPage();
|
|
// Studio's default layout is sized for a laptop window; the tab-strip check below depends on it.
|
|
await page.setViewport({ width: 1440, height: 900 });
|
|
const errors = [];
|
|
const unmockedApiRequests = [];
|
|
|
|
page.on("pageerror", (error) => errors.push(error.message));
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") errors.push(message.text());
|
|
});
|
|
await page.setRequestInterception(true);
|
|
page.on("request", (request) => {
|
|
const response = studioSmokeApiResponse(request.method(), request.url());
|
|
if (response === undefined) {
|
|
void request.continue();
|
|
return;
|
|
}
|
|
if (response === null) {
|
|
unmockedApiRequests.push(`${request.method()} ${new URL(request.url()).pathname}`);
|
|
void request.respond(json({ error: "unmocked smoke endpoint" }, 501));
|
|
return;
|
|
}
|
|
void request.respond(response);
|
|
});
|
|
|
|
try {
|
|
await page.goto(targetUrl, { waitUntil: "networkidle0", timeout: 30_000 });
|
|
await new Promise((resolve) => setTimeout(resolve, 3_000));
|
|
const errorBoundary = await page.evaluate(() => {
|
|
const textContent = document.body.innerText;
|
|
return textContent.includes("Something went wrong") ? textContent : null;
|
|
});
|
|
if (errorBoundary) errors.push(`React error boundary triggered: ${errorBoundary}`);
|
|
errors.push(...(await dockStripErrors(page)));
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
|
|
const fatal = errors.filter((error) => !isExpectedStudioSmokeError(error));
|
|
const failures = [
|
|
...new Set(unmockedApiRequests.map((request) => `unmocked API request: ${request}`)),
|
|
...fatal,
|
|
];
|
|
if (failures.length > 0) {
|
|
throw new Error(
|
|
`Studio runtime smoke failed:\n${failures.map((error) => `- ${error}`).join("\n")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const targetUrl = process.argv[2] ?? "http://localhost:5199/#project=smoke-test";
|
|
await runStudioRuntimeSmoke(targetUrl);
|
|
console.log("PASS: studio loaded with schema-valid API fixtures and no runtime errors");
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|