|
| 1 | +# Working on Studio |
| 2 | + |
| 3 | +Read this before your first change in `packages/studio`. It is the handful of |
| 4 | +things that are not visible from the source, and that cost real time to |
| 5 | +rediscover. |
| 6 | + |
| 7 | +## The shape of the thing |
| 8 | + |
| 9 | +Studio renders the user's composition in an **iframe**, and draws its own |
| 10 | +chrome — selection box, handles, dashed outlines, toolbars — in **Studio's own |
| 11 | +document**, positioned over the iframe. Nothing Studio draws lives inside the |
| 12 | +composition, because a render would capture it and the composition's styling |
| 13 | +would inherit into it. |
| 14 | + |
| 15 | +Two consequences you will meet immediately: |
| 16 | + |
| 17 | +- Reaching a preview element from a driver or a test means going through the |
| 18 | + iframe: `iframe.contentDocument.getElementById(...)`. Studio's own panels may |
| 19 | + be inside shadow roots, so a plain `document.querySelector` finds neither. |
| 20 | +- Every overlay box is a *measurement* of an element, not the element. When |
| 21 | + chrome disagrees with the pixels underneath it, the bug is almost always in |
| 22 | + the measurement, in `components/editor/domEditOverlayGeometry.ts`. |
| 23 | + |
| 24 | +## Driving Studio for verification |
| 25 | + |
| 26 | +A pixel-precise click inside the preview is not something an automated driver |
| 27 | +can reliably land, and some gestures cannot be synthesised at all: the canvas |
| 28 | +overlay takes pointer capture and recognises a double press itself, so |
| 29 | +`page.mouse` click pairs do not open a text edit no matter how they are timed. |
| 30 | + |
| 31 | +Use the dev-only hook instead. In a dev build `window.__studioTest` exposes: |
| 32 | + |
| 33 | +```js |
| 34 | +await window.__studioTest.selectByDomId("headline"); // selects, reveals the inspector |
| 35 | +``` |
| 36 | + |
| 37 | +That is the same selection a click produces. The general lesson: from a settled |
| 38 | +selection, keyboard paths are dependable where pointer paths are not. Prefer a |
| 39 | +key over a synthesised gesture whenever the feature offers one. |
| 40 | + |
| 41 | +`useStudioTestHooks` also carries the timeline performance fixtures. The hook is |
| 42 | +gated on `STUDIO_TEST_HOOKS_ENABLED` (dev or development mode only), so |
| 43 | +`window.__studioTest` is absent in production builds — feature-detect it. |
| 44 | + |
| 45 | +## Tracing decisions |
| 46 | + |
| 47 | +The interesting failures here are decisions, not crashes: a preview that |
| 48 | +reloads when it should not, a shift-click that selects the wrong element. |
| 49 | +Nothing throws, so a trace of the decision is the only way to avoid guessing. |
| 50 | + |
| 51 | +Channels are off by default. Turn one on and reload: |
| 52 | + |
| 53 | +```js |
| 54 | +localStorage.setItem("hf-drag-debug", "1"); // then grep the console for [hf-drag] |
| 55 | +``` |
| 56 | + |
| 57 | +Live channels: `reload`, `select`, `drag`, `resize`, `commit`. Add one with |
| 58 | +`makeStudioDebugLogger("<name>")` in `utils/studioDebug.ts`. |
| 59 | + |
| 60 | +## Running the tests |
| 61 | + |
| 62 | +Studio's tests are **vitest**, not `bun test`. Running bare `bun test` in this |
| 63 | +package collects the files with the wrong runner and reports failures that are |
| 64 | +not real: |
| 65 | + |
| 66 | +```bash |
| 67 | +bun run --cwd packages/studio test # all of them |
| 68 | +bun run --cwd packages/studio test src/components/editor # one directory |
| 69 | +``` |
| 70 | + |
| 71 | +happy-dom is not a browser. It does not reflect the individual transform |
| 72 | +properties (`rotate`, `scale`, `translate`) into computed style, and it has no |
| 73 | +`DOMMatrix` — the geometry tests carry their own stand-in. When a behaviour |
| 74 | +depends on real layout or real computed style, prove it in a browser and keep |
| 75 | +the unit test on the pure function underneath. |
| 76 | + |
| 77 | +## Gates that will fail your PR |
| 78 | + |
| 79 | +- **600 lines per file.** CI checks only non-test files your PR changed. A file |
| 80 | + that grows past it has to be split in the same PR that grew it. |
| 81 | +- **`bunx fallow audit --base origin/main --fail-on-issues`** — complexity per |
| 82 | + function, duplication, unused exports. Adding branches to an already-complex |
| 83 | + function trips it; extract rather than nest. |
| 84 | +- **oxlint and oxfmt**, not eslint or prettier. |
| 85 | + |
| 86 | +## Traps worth knowing |
| 87 | + |
| 88 | +- **`rotate` is not `transform`.** Studio's rotate handle writes the CSS |
| 89 | + `rotate` property, which is an individual transform property and does not |
| 90 | + appear in `getComputedStyle(el).transform`. Anything measuring an angle has to |
| 91 | + read both and compose them the way CSS does, individual properties first. |
| 92 | +- **A seek re-renders the whole timeline**, not the tween you patched. Patching |
| 93 | + several elements one at a time and seeking after each repaints the ones still |
| 94 | + queued from their un-patched tweens. Batch, then render once. |
| 95 | +- **Studio's own writes must not reload the preview.** Writes carry a token so |
| 96 | + the file-watcher event can be recognised as ours; a new write path that |
| 97 | + forgets it makes the preview flash on every edit. |
| 98 | +- **Preserving a selection set that does not contain the id empties it.** Check |
| 99 | + `preserveSet` semantics before reusing it. |
0 commit comments