@hyperframes/studio 0.8.137 → 0.8.139

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/assets/{hyperframes-player-D_Mh6hbx.js → hyperframes-player-CckOoCJ8.js} +1 -1
  2. package/dist/assets/{index-CHEctGzc.js → index-BmIPrb8I.js} +1 -1
  3. package/dist/assets/{index-B3lJsA-e.js → index-Br1Whc0f.js} +189 -189
  4. package/dist/assets/{index-CC_E-Cbl.css → index-CVd4O6eH.css} +1 -1
  5. package/dist/assets/{index-rtccfg9b.js → index-DsEkE5_G.js} +1 -1
  6. package/dist/{chunk-4S3AYCOG.js → chunk-E422JDF5.js} +2 -2
  7. package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
  8. package/dist/chunk-TFLU2MGI.js.map +1 -0
  9. package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
  10. package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +867 -499
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +2 -2
  16. package/dist/styles.css +1 -1
  17. package/package.json +9 -8
  18. package/src/App.tsx +11 -11
  19. package/src/components/editor/DomEditOverlay.tsx +9 -5
  20. package/src/components/editor/LayersPanel.test.ts +25 -0
  21. package/src/components/editor/LayersPanel.tsx +1 -1
  22. package/src/components/editor/PropertyPanel.test.tsx +600 -748
  23. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
  24. package/src/components/editor/domEditOverlayGeometry.ts +7 -7
  25. package/src/components/editor/domEditOverlayGestures.ts +18 -0
  26. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
  27. package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
  28. package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
  29. package/src/components/editor/dragUndoPaint.test.ts +2 -0
  30. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
  31. package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
  32. package/src/components/editor/manualEditsDom.ts +7 -7
  33. package/src/components/editor/manualEditsTypes.ts +5 -0
  34. package/src/components/editor/manualOffsetDrag.test.ts +23 -0
  35. package/src/components/editor/manualOffsetDrag.ts +5 -0
  36. package/src/components/editor/previewGestureStarts.ts +284 -0
  37. package/src/components/editor/rotateInlineSave.test.ts +1 -0
  38. package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
  39. package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
  40. package/src/components/nle/NLEContext.test.ts +26 -0
  41. package/src/components/nle/NLEContext.tsx +8 -3
  42. package/src/hooks/draggedGsapPosition.ts +8 -0
  43. package/src/hooks/editMoment.ts +13 -0
  44. package/src/hooks/gsapDragCommit.ts +2 -1
  45. package/src/hooks/gsapDragPositionCommit.ts +19 -7
  46. package/src/hooks/gsapPositionDetection.ts +5 -3
  47. package/src/hooks/gsapResizeIntercept.ts +11 -4
  48. package/src/hooks/gsapRuntimeBridge.ts +31 -11
  49. package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
  50. package/src/hooks/gsapValueAtPlayhead.ts +12 -5
  51. package/src/hooks/timelineEditingHelpers.test.ts +22 -0
  52. package/src/hooks/timelineEditingHelpers.ts +18 -2
  53. package/src/hooks/useConsoleErrorCapture.test.tsx +87 -0
  54. package/src/hooks/useConsoleErrorCapture.ts +18 -22
  55. package/src/hooks/useDomSelection.test.ts +21 -2
  56. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  57. package/src/hooks/useEditHistoryActions.ts +2 -0
  58. package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
  59. package/src/hooks/useGsapAwareEditing.ts +148 -136
  60. package/src/hooks/useGsapAwareGroupMove.ts +126 -113
  61. package/src/hooks/useGsapScriptCommits.ts +5 -4
  62. package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
  63. package/src/hooks/usePreviewPersistence.ts +1 -4
  64. package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
  65. package/src/hooks/useTimelineDeleteOps.ts +9 -10
  66. package/src/index.ts +1 -0
  67. package/src/player/hooks/useShadowPreviewReload.ts +6 -6
  68. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
  69. package/src/player/hooks/useTimelinePlayer.ts +8 -5
  70. package/src/player/lib/thumbnailScheduler.test.ts +20 -1
  71. package/src/player/lib/thumbnailScheduler.ts +1 -1
  72. package/src/player/lib/timelineElementHelpers.ts +0 -11
  73. package/src/player/previewReloading.test.ts +48 -0
  74. package/src/player/previewReloading.ts +48 -0
  75. package/src/styles/studio.css +9 -0
  76. package/src/utils/gsapUndoRestore.browser.test.ts +21 -12
  77. package/src/utils/rootDuration.ts +5 -4
  78. package/src/utils/studioPendingEdits.test.ts +27 -0
  79. package/src/utils/studioPendingEdits.ts +9 -0
  80. package/src/utils/timelineAssetDrop.test.ts +13 -4
  81. package/src/utils/timelineAssetDrop.ts +6 -2
  82. package/dist/chunk-TDWGXBUT.js.map +0 -1
  83. /package/dist/{chunk-4S3AYCOG.js.map → chunk-E422JDF5.js.map} +0 -0
  84. /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
  85. /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
@@ -5,6 +5,9 @@ import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import type { PropertyPanelProps } from "./propertyPanelHelpers";
7
7
  import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
8
+ import { PropertyPanel } from "./PropertyPanel";
9
+ import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
10
+ import { usePlayerStore } from "../../player/store/playerStore";
8
11
 
9
12
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
13
 
@@ -17,10 +20,35 @@ vi.mock("../../contexts/StudioContext", async () => {
17
20
  return { ...actual, useStudioShellContext: () => ({ showToast: vi.fn() }) };
18
21
  });
19
22
 
23
+ // The panel is imported once: re-importing it per test made the first test pay a cold transform.
24
+ const panel = vi.hoisted(() => ({
25
+ flat: false,
26
+ actions: null as null | {
27
+ refreshDomEditSelectionFromPreview: unknown;
28
+ domEditSelectionRef: unknown;
29
+ },
30
+ }));
31
+ vi.mock("./manualEditingAvailability", async (importOriginal) => ({
32
+ ...(await importOriginal<typeof import("./manualEditingAvailability")>()),
33
+ get STUDIO_FLAT_INSPECTOR_ENABLED() {
34
+ return panel.flat;
35
+ },
36
+ }));
37
+ vi.mock("../../contexts/DomEditContext", async (importOriginal) => {
38
+ const actual = await importOriginal<typeof import("../../contexts/DomEditContext")>();
39
+ return {
40
+ ...actual,
41
+ useDomEditActionsContextOptional: () =>
42
+ panel.actions ?? actual.useDomEditActionsContextOptional(),
43
+ };
44
+ });
45
+
46
+ const initialPlayerState = usePlayerStore.getState();
47
+
20
48
  afterEach(() => {
21
49
  document.body.innerHTML = "";
22
- vi.doUnmock("./manualEditingAvailability");
23
- vi.resetModules();
50
+ panel.actions = null;
51
+ usePlayerStore.setState(initialPlayerState, true);
24
52
  });
25
53
 
26
54
  function baseElement(): NonNullable<PropertyPanelProps["element"]> {
@@ -244,39 +272,17 @@ async function renderPanel(
244
272
  selectionRef?: { current: unknown };
245
273
  } = {},
246
274
  ) {
247
- vi.resetModules();
248
- vi.doMock("./manualEditingAvailability", async () => {
249
- const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
250
- "./manualEditingAvailability",
251
- );
252
- return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: flatEnabled };
253
- });
254
- vi.doMock("../../contexts/DomEditContext", async () => {
255
- const actual = await vi.importActual<typeof import("../../contexts/DomEditContext")>(
256
- "../../contexts/DomEditContext",
257
- );
258
- const { refreshSelection: refresh, selectionRef } = options;
259
- return refresh && selectionRef
260
- ? {
261
- ...actual,
262
- useDomEditActionsContextOptional: () => ({
263
- refreshDomEditSelectionFromPreview: refresh,
264
- domEditSelectionRef: selectionRef,
265
- }),
266
- }
267
- : actual;
268
- });
269
- // Seed the playhead and selection on the same store instance PropertyPanel.tsx
270
- // reads (module-fresh after the resetModules() above), before it is imported so
271
- // its first render sees them.
272
- const { usePlayerStore } = await import("../../player/store/playerStore");
275
+ panel.flat = flatEnabled;
276
+ const { refreshSelection: refresh, selectionRef } = options;
277
+ panel.actions =
278
+ refresh && selectionRef
279
+ ? { refreshDomEditSelectionFromPreview: refresh, domEditSelectionRef: selectionRef }
280
+ : null;
273
281
  if (options.currentTime !== undefined) {
274
282
  usePlayerStore.getState().setCurrentTime(options.currentTime);
275
283
  }
276
284
  if (options.selectedElementId)
277
285
  usePlayerStore.setState({ selectedElementId: options.selectedElementId });
278
- const { PropertyPanel } = await import("./PropertyPanel");
279
- const { TimelineEditProvider } = await import("../../contexts/TimelineEditContext");
280
286
  const host = document.createElement("div");
281
287
  document.body.append(host);
282
288
  const root = createRoot(host);
@@ -304,15 +310,6 @@ async function renderPanel(
304
310
  return { host, root, render };
305
311
  }
306
312
 
307
- // renderPanel resetModules()+dynamic-imports PropertyPanel (needed for a fresh
308
- // flag read); transforming the full section graph uncached can exceed the 5s
309
- // default under heavy parallel full-suite load, so give these a wider margin.
310
- // 20s itself has now been observed timing out in CI's full-monorepo run (the
311
- // same suite passes in well under 2s standalone) — widened again rather than
312
- // re-tuned down to a number that will just need doing again next time CI adds
313
- // load.
314
- const RENDER_TIMEOUT_MS = 45_000;
315
-
316
313
  // Find the collapsed accordion row whose title matches and click it open.
317
314
  function openFlatGroup(host: HTMLElement, title: string) {
318
315
  const row = Array.from(host.querySelectorAll('[data-flat-group-collapsed="true"]')).find((el) =>
@@ -326,229 +323,181 @@ const openGroupText = (host: HTMLElement) =>
326
323
  host.querySelector('[data-flat-group-open="true"]')?.textContent ?? "";
327
324
 
328
325
  describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED off", () => {
329
- it(
330
- "renders the legacy header, not the flat header",
331
- async () => {
332
- const { host, root } = await renderPanel(false);
333
- expect(host.querySelector('[data-flat-header-icon="true"]')).toBeNull();
334
- expect(host.textContent).toContain("Mono Label");
335
- act(() => root.unmount());
336
- },
337
- RENDER_TIMEOUT_MS,
338
- );
326
+ it("renders the legacy header, not the flat header", async () => {
327
+ const { host, root } = await renderPanel(false);
328
+ expect(host.querySelector('[data-flat-header-icon="true"]')).toBeNull();
329
+ expect(host.textContent).toContain("Mono Label");
330
+ act(() => root.unmount());
331
+ });
339
332
  });
340
333
 
341
334
  describe("PropertyPanel — STUDIO_FLAT_INSPECTOR_ENABLED on", () => {
342
- it(
343
- "renders the flat header, the Text group open by default, and the flat footer",
344
- async () => {
345
- const { host, root } = await renderPanel(true);
346
- expect(host.querySelector('[data-flat-header-icon="true"]')).not.toBeNull();
347
- expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
348
- expect(host.textContent).toContain("Ask agent about this element");
349
- act(() => root.unmount());
350
- },
351
- RENDER_TIMEOUT_MS,
352
- );
335
+ it("renders the flat header, the Text group open by default, and the flat footer", async () => {
336
+ const { host, root } = await renderPanel(true);
337
+ expect(host.querySelector('[data-flat-header-icon="true"]')).not.toBeNull();
338
+ expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
339
+ expect(host.textContent).toContain("Ask agent about this element");
340
+ act(() => root.unmount());
341
+ });
353
342
 
354
- it(
355
- "collapses the Text group on caret click and can reopen it",
356
- async () => {
357
- const { host, root } = await renderPanel(true);
358
- const collapseButton = host.querySelector<HTMLButtonElement>(
359
- '[data-flat-group-open="true"] button[title="Collapse"]',
360
- );
361
- act(() => collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
362
- expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull();
363
- const collapsedRow = host.querySelector<HTMLButtonElement>(
364
- '[data-flat-group-collapsed="true"]',
365
- );
366
- expect(collapsedRow).not.toBeNull();
367
- act(() => collapsedRow?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
368
- expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
369
- act(() => root.unmount());
370
- },
371
- RENDER_TIMEOUT_MS,
372
- );
343
+ it("collapses the Text group on caret click and can reopen it", async () => {
344
+ const { host, root } = await renderPanel(true);
345
+ const collapseButton = host.querySelector<HTMLButtonElement>(
346
+ '[data-flat-group-open="true"] button[title="Collapse"]',
347
+ );
348
+ act(() => collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
349
+ expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull();
350
+ const collapsedRow = host.querySelector<HTMLButtonElement>(
351
+ '[data-flat-group-collapsed="true"]',
352
+ );
353
+ expect(collapsedRow).not.toBeNull();
354
+ act(() => collapsedRow?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
355
+ expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
356
+ act(() => root.unmount());
357
+ });
373
358
 
374
- it(
375
- "renders no Text group at all for a non-text element (bug 1)",
376
- async () => {
377
- // nonTextElement() inherits canEditStyles: true from baseElement(), so
378
- // the Style group (Task 10) renders and opens by default here — the
379
- // invariant under test is narrower than "no flat group at all": no
380
- // group titled "Text" may appear, open or collapsed.
381
- const { host, root } = await renderPanel(true, nonTextElement());
382
- const openTitle = host.querySelector(
383
- '[data-flat-group-open="true"] .text-panel-text-0',
384
- )?.textContent;
385
- const collapsedTitles = Array.from(
386
- host.querySelectorAll('[data-flat-group-collapsed="true"] .text-panel-text-2'),
387
- ).map((el) => el.textContent);
388
- expect(openTitle).not.toBe("Text");
389
- expect(collapsedTitles).not.toContain("Text");
390
- act(() => root.unmount());
391
- },
392
- RENDER_TIMEOUT_MS,
393
- );
359
+ it("renders no Text group at all for a non-text element (bug 1)", async () => {
360
+ // nonTextElement() inherits canEditStyles: true from baseElement(), so
361
+ // the Style group (Task 10) renders and opens by default here — the
362
+ // invariant under test is narrower than "no flat group at all": no
363
+ // group titled "Text" may appear, open or collapsed.
364
+ const { host, root } = await renderPanel(true, nonTextElement());
365
+ const openTitle = host.querySelector(
366
+ '[data-flat-group-open="true"] .text-panel-text-0',
367
+ )?.textContent;
368
+ const collapsedTitles = Array.from(
369
+ host.querySelectorAll('[data-flat-group-collapsed="true"] .text-panel-text-2'),
370
+ ).map((el) => el.textContent);
371
+ expect(openTitle).not.toBe("Text");
372
+ expect(collapsedTitles).not.toContain("Text");
373
+ act(() => root.unmount());
374
+ });
394
375
 
395
- it(
396
- "renders exactly one Text heading for a multi-field text element (bug 2)",
397
- async () => {
398
- const { host, root } = await renderPanel(true, multiFieldTextElement());
399
- // The FlatGroup's own "Text" heading is the only one that should exist —
400
- // the legacy TextSection's internal Section heading (data-panel-section
401
- // ="text") must never appear, since the flat multi-field path no longer
402
- // delegates to that component at all.
403
- expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
404
- expect(host.querySelector('[data-panel-section="text"]')).toBeNull();
405
- // Content from the flat multi-field layer list must render.
406
- expect(host.textContent).toContain("Text layers");
407
- act(() => root.unmount());
408
- },
409
- RENDER_TIMEOUT_MS,
410
- );
376
+ it("renders exactly one Text heading for a multi-field text element (bug 2)", async () => {
377
+ const { host, root } = await renderPanel(true, multiFieldTextElement());
378
+ // The FlatGroup's own "Text" heading is the only one that should exist —
379
+ // the legacy TextSection's internal Section heading (data-panel-section
380
+ // ="text") must never appear, since the flat multi-field path no longer
381
+ // delegates to that component at all.
382
+ expect(host.querySelector('[data-flat-group-open="true"]')).not.toBeNull();
383
+ expect(host.querySelector('[data-panel-section="text"]')).toBeNull();
384
+ // Content from the flat multi-field layer list must render.
385
+ expect(host.textContent).toContain("Text layers");
386
+ act(() => root.unmount());
387
+ });
411
388
  });
412
389
 
413
390
  describe("PropertyPanel — Style group (flag on)", () => {
414
- it(
415
- "renders the Style group for a style-editable, non-text element",
416
- async () => {
417
- const { host, root } = await renderPanel(true, styleOnlyElement());
418
- expect(host.textContent).toContain("Style");
419
- expect(host.textContent).toContain("Fill");
420
- act(() => root.unmount());
421
- },
422
- RENDER_TIMEOUT_MS,
423
- );
391
+ it("renders the Style group for a style-editable, non-text element", async () => {
392
+ const { host, root } = await renderPanel(true, styleOnlyElement());
393
+ expect(host.textContent).toContain("Style");
394
+ expect(host.textContent).toContain("Fill");
395
+ act(() => root.unmount());
396
+ });
424
397
 
425
- it(
426
- "one-open accordion: opening Style closes Text",
427
- async () => {
428
- // baseElement() is text-editable and has capabilities.canEditStyles:
429
- // true, so both the Text and Style groups render for it.
430
- const { host, root } = await renderPanel(true);
431
- const textGroup = () => host.querySelector('[data-flat-group-open="true"]');
432
- expect(textGroup()?.textContent).toContain("Text");
433
- const styleCollapsedRow = Array.from(
434
- host.querySelectorAll('[data-flat-group-collapsed="true"]'),
435
- ).find((el) => el.textContent?.includes("Style"));
436
- if (!styleCollapsedRow) throw new Error("expected a collapsed Style row");
437
- act(() => styleCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
438
- expect(textGroup()?.textContent).not.toContain("Text");
439
- expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Style");
440
- act(() => root.unmount());
441
- },
442
- RENDER_TIMEOUT_MS,
443
- );
398
+ it("one-open accordion: opening Style closes Text", async () => {
399
+ // baseElement() is text-editable and has capabilities.canEditStyles:
400
+ // true, so both the Text and Style groups render for it.
401
+ const { host, root } = await renderPanel(true);
402
+ const textGroup = () => host.querySelector('[data-flat-group-open="true"]');
403
+ expect(textGroup()?.textContent).toContain("Text");
404
+ const styleCollapsedRow = Array.from(
405
+ host.querySelectorAll('[data-flat-group-collapsed="true"]'),
406
+ ).find((el) => el.textContent?.includes("Style"));
407
+ if (!styleCollapsedRow) throw new Error("expected a collapsed Style row");
408
+ act(() => styleCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
409
+ expect(textGroup()?.textContent).not.toContain("Text");
410
+ expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Style");
411
+ act(() => root.unmount());
412
+ });
444
413
  });
445
414
 
446
415
  describe("PropertyPanel — Layout group (Plan 3a)", () => {
447
- it(
448
- "always renders the Layout group, and opening it closes whichever other group was open",
449
- async () => {
450
- const { host, root } = await renderPanel(true);
451
- // Text group is open by default for the base text-editable fixture.
452
- expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Text");
453
-
454
- const layoutCollapsedRow = Array.from(
455
- host.querySelectorAll('[data-flat-group-collapsed="true"]'),
456
- ).find((el) => el.textContent?.includes("Layout"));
457
- if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row");
458
- act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
459
-
460
- const openGroup = host.querySelector('[data-flat-group-open="true"]');
461
- expect(openGroup?.textContent).toContain("Layout");
462
- expect(openGroup?.textContent).toContain("X");
463
- expect(openGroup?.textContent).not.toContain("Ask agent"); // sanity: not matching the footer
464
- act(() => root.unmount());
465
- },
466
- RENDER_TIMEOUT_MS,
467
- );
416
+ it("always renders the Layout group, and opening it closes whichever other group was open", async () => {
417
+ const { host, root } = await renderPanel(true);
418
+ // Text group is open by default for the base text-editable fixture.
419
+ expect(host.querySelector('[data-flat-group-open="true"]')?.textContent).toContain("Text");
420
+
421
+ const layoutCollapsedRow = Array.from(
422
+ host.querySelectorAll('[data-flat-group-collapsed="true"]'),
423
+ ).find((el) => el.textContent?.includes("Layout"));
424
+ if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row");
425
+ act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
426
+
427
+ const openGroup = host.querySelector('[data-flat-group-open="true"]');
428
+ expect(openGroup?.textContent).toContain("Layout");
429
+ expect(openGroup?.textContent).toContain("X");
430
+ expect(openGroup?.textContent).not.toContain("Ask agent"); // sanity: not matching the footer
431
+ act(() => root.unmount());
432
+ });
468
433
 
469
- it(
470
- "renders Flex exactly once on the flat path (flat Layout only, legacy suppressed)",
471
- async () => {
472
- const { host, root } = await renderPanel(true, flexElement());
473
- const layoutCollapsedRow = Array.from(
474
- host.querySelectorAll('[data-flat-group-collapsed="true"]'),
475
- ).find((el) => el.textContent?.includes("Layout"));
476
- if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row");
477
- act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
478
-
479
- // The legacy StyleSections Flex `Section` (data-panel-section="flex") must
480
- // NOT render on the flat path — the only two Flex renderers are the legacy
481
- // Section and the flat LayoutFlexBlock, so its absence + the flat block's
482
- // presence proves Flex renders exactly once (not twice, not zero).
483
- expect(host.querySelector('[data-panel-section="flex"]')).toBeNull();
484
- const openGroup = host.querySelector('[data-flat-group-open="true"]');
485
- expect(openGroup?.textContent).toContain("Layout");
486
- expect(openGroup?.textContent).toContain("Flex");
487
- act(() => root.unmount());
488
- },
489
- RENDER_TIMEOUT_MS,
490
- );
434
+ it("renders Flex exactly once on the flat path (flat Layout only, legacy suppressed)", async () => {
435
+ const { host, root } = await renderPanel(true, flexElement());
436
+ const layoutCollapsedRow = Array.from(
437
+ host.querySelectorAll('[data-flat-group-collapsed="true"]'),
438
+ ).find((el) => el.textContent?.includes("Layout"));
439
+ if (!layoutCollapsedRow) throw new Error("expected a collapsed Layout row");
440
+ act(() => layoutCollapsedRow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
441
+
442
+ // The legacy StyleSections Flex `Section` (data-panel-section="flex") must
443
+ // NOT render on the flat path — the only two Flex renderers are the legacy
444
+ // Section and the flat LayoutFlexBlock, so its absence + the flat block's
445
+ // presence proves Flex renders exactly once (not twice, not zero).
446
+ expect(host.querySelector('[data-panel-section="flex"]')).toBeNull();
447
+ const openGroup = host.querySelector('[data-flat-group-open="true"]');
448
+ expect(openGroup?.textContent).toContain("Layout");
449
+ expect(openGroup?.textContent).toContain("Flex");
450
+ act(() => root.unmount());
451
+ });
491
452
  });
492
453
 
493
454
  describe("PropertyPanel — Motion group (Plan 3b)", () => {
494
- it(
495
- "renders the Motion group with Timing, and opening it closes the previously open group (4-way exclusivity)",
496
- async () => {
497
- const { host, root } = await renderPanel(true, animatedElement());
498
- // Text is open by default for the text-editable fixture.
499
- expect(openGroupText(host)).toContain("Text");
500
-
501
- openFlatGroup(host, "Motion");
502
- const openGroup = openGroupText(host);
503
- expect(openGroup).toContain("Motion");
504
- // FlatTimingRow (Start/End/Duration) renders inside the Motion group.
505
- expect(openGroup).toContain("Start");
506
- expect(openGroup).toContain("Duration");
507
- // One-open accordion: opening Motion closed the Text group.
508
- expect(openGroup).not.toContain("Text");
509
-
510
- // Reverse direction: opening Layout closes Motion.
511
- openFlatGroup(host, "Layout");
512
- const openAfter = openGroupText(host);
513
- expect(openAfter).toContain("Layout");
514
- expect(openAfter).not.toContain("Motion");
515
- act(() => root.unmount());
516
- },
517
- RENDER_TIMEOUT_MS,
518
- );
455
+ it("renders the Motion group with Timing, and opening it closes the previously open group (4-way exclusivity)", async () => {
456
+ const { host, root } = await renderPanel(true, animatedElement());
457
+ // Text is open by default for the text-editable fixture.
458
+ expect(openGroupText(host)).toContain("Text");
459
+
460
+ openFlatGroup(host, "Motion");
461
+ const openGroup = openGroupText(host);
462
+ expect(openGroup).toContain("Motion");
463
+ // FlatTimingRow (Start/End/Duration) renders inside the Motion group.
464
+ expect(openGroup).toContain("Start");
465
+ expect(openGroup).toContain("Duration");
466
+ // One-open accordion: opening Motion closed the Text group.
467
+ expect(openGroup).not.toContain("Text");
468
+
469
+ // Reverse direction: opening Layout closes Motion.
470
+ openFlatGroup(host, "Layout");
471
+ const openAfter = openGroupText(host);
472
+ expect(openAfter).toContain("Layout");
473
+ expect(openAfter).not.toContain("Motion");
474
+ act(() => root.unmount());
475
+ });
519
476
 
520
- it(
521
- "hides the effect list (showEffects off) when the GSAP edit handlers are absent",
522
- async () => {
523
- // None of the five required edit handlers are supplied here, so the
524
- // effect list stays closed — only the Timing row shows.
525
- const { host, root } = await renderPanel(true, animatedElement());
526
- openFlatGroup(host, "Motion");
527
- const openGroup = openGroupText(host);
528
- expect(openGroup).toContain("Motion");
529
- expect(openGroup).toContain("Duration"); // Timing still shows
530
- expect(openGroup).not.toContain("Add effect"); // effects gated off
531
- act(() => root.unmount());
532
- },
533
- RENDER_TIMEOUT_MS,
534
- );
477
+ it("hides the effect list (showEffects off) when the GSAP edit handlers are absent", async () => {
478
+ // None of the five required edit handlers are supplied here, so the
479
+ // effect list stays closed — only the Timing row shows.
480
+ const { host, root } = await renderPanel(true, animatedElement());
481
+ openFlatGroup(host, "Motion");
482
+ const openGroup = openGroupText(host);
483
+ expect(openGroup).toContain("Motion");
484
+ expect(openGroup).toContain("Duration"); // Timing still shows
485
+ expect(openGroup).not.toContain("Add effect"); // effects gated off
486
+ act(() => root.unmount());
487
+ });
535
488
 
536
- it(
537
- "shows the effect list (showEffects on) when the flag and all five handlers are present",
538
- async () => {
539
- const { host, root } = await renderPanel(true, animatedElement(), {
540
- onUpdateGsapProperty: vi.fn(),
541
- onUpdateGsapMeta: vi.fn(),
542
- onDeleteGsapAnimation: vi.fn(),
543
- onAddGsapProperty: vi.fn(),
544
- onAddGsapAnimation: vi.fn(),
545
- });
546
- openFlatGroup(host, "Motion");
547
- expect(openGroupText(host)).toContain("Add effect");
548
- act(() => root.unmount());
549
- },
550
- RENDER_TIMEOUT_MS,
551
- );
489
+ it("shows the effect list (showEffects on) when the flag and all five handlers are present", async () => {
490
+ const { host, root } = await renderPanel(true, animatedElement(), {
491
+ onUpdateGsapProperty: vi.fn(),
492
+ onUpdateGsapMeta: vi.fn(),
493
+ onDeleteGsapAnimation: vi.fn(),
494
+ onAddGsapProperty: vi.fn(),
495
+ onAddGsapAnimation: vi.fn(),
496
+ });
497
+ openFlatGroup(host, "Motion");
498
+ expect(openGroupText(host)).toContain("Add effect");
499
+ act(() => root.unmount());
500
+ });
552
501
  });
553
502
 
554
503
  // Whole-plan coherence fix: Layout's keyframe-seek basis and Motion's Timing
@@ -557,69 +506,55 @@ describe("PropertyPanel — Motion group (Plan 3b)", () => {
557
506
  // to a naive `duration ?? 1` while Motion correctly inferred the range from
558
507
  // the tween (position 2, duration 3 -> start 2 / duration 3 / end 5).
559
508
  describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan coherence fix)", () => {
560
- it(
561
- "Motion's Timing row shows the inferred start/end/duration for an element with animations but no explicit duration",
562
- async () => {
563
- const { host, root } = await renderPanel(true, inferredMotionElement(), {
564
- gsapAnimations: [INFERRED_TIMING_ANIMATION],
565
- });
566
- openFlatGroup(host, "Motion");
567
- const motionGroup = host.querySelector('[data-flat-group-open="true"]');
568
- if (!motionGroup) throw new Error("expected the Motion group to be open");
569
- expect(motionGroup.textContent).toContain("Inferred");
570
- const inputs = motionGroup.querySelectorAll<HTMLInputElement>("input");
571
- // FlatTimingRow renders Start, End, Duration in that order.
572
- expect(inputs[0]?.value).toBe("2.00s");
573
- expect(inputs[1]?.value).toBe("5.00s");
574
- expect(inputs[2]?.value).toBe("3.00s");
575
- act(() => root.unmount());
576
- },
577
- RENDER_TIMEOUT_MS,
578
- );
509
+ it("Motion's Timing row shows the inferred start/end/duration for an element with animations but no explicit duration", async () => {
510
+ const { host, root } = await renderPanel(true, inferredMotionElement(), {
511
+ gsapAnimations: [INFERRED_TIMING_ANIMATION],
512
+ });
513
+ openFlatGroup(host, "Motion");
514
+ const motionGroup = host.querySelector('[data-flat-group-open="true"]');
515
+ if (!motionGroup) throw new Error("expected the Motion group to be open");
516
+ expect(motionGroup.textContent).toContain("Inferred");
517
+ const inputs = motionGroup.querySelectorAll<HTMLInputElement>("input");
518
+ // FlatTimingRow renders Start, End, Duration in that order.
519
+ expect(inputs[0]?.value).toBe("2.00s");
520
+ expect(inputs[1]?.value).toBe("5.00s");
521
+ expect(inputs[2]?.value).toBe("3.00s");
522
+ act(() => root.unmount());
523
+ });
579
524
 
580
- it(
581
- "Layout's X-row keyframe gutter seeks to the SAME absolute time Motion's Timing row shows as the midpoint (50% of an inferred 2s-5s range = 3.5s)",
582
- async () => {
583
- const onSeekToTime = vi.fn();
584
- // Seed the playhead at the clip's real start (t=2, the 0% keyframe's
585
- // absolute time) — now that the follow-up fix also recomputes
586
- // `currentPct` from the corrected elStart/elDuration basis, "current
587
- // position is at the 0% keyframe" must be expressed as an actual t=2
588
- // seek rather than relying on the store's untouched t=0 default (which,
589
- // post-fix, resolves to a currentPct of -66.7% — well outside the 0%
590
- // keyframe's tolerance window, and no longer "the case the coherence
591
- // bug affected" that this test documents).
592
- const { host, root } = await renderPanel(
593
- true,
594
- inferredMotionElement(),
595
- { gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
596
- { currentTime: 2 },
597
- );
598
- openFlatGroup(host, "Layout");
599
- const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
600
- if (!layoutGroup) throw new Error("expected the Layout group to be open");
525
+ it("Layout's X-row keyframe gutter seeks to the SAME absolute time Motion's Timing row shows as the midpoint (50% of an inferred 2s-5s range = 3.5s)", async () => {
526
+ const onSeekToTime = vi.fn();
527
+ // The playhead sits at the clip's start (t=2, the 0% keyframe): the store's t=0 default
528
+ // resolves to -66.7%, outside that keyframe's window.
529
+ const { host, root } = await renderPanel(
530
+ true,
531
+ inferredMotionElement(),
532
+ { gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
533
+ { currentTime: 2 },
534
+ );
535
+ openFlatGroup(host, "Layout");
536
+ const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
537
+ if (!layoutGroup) throw new Error("expected the Layout group to be open");
601
538
 
602
- const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
603
- (el) => el.querySelector("span")?.textContent === "X",
604
- );
605
- if (!xRow) throw new Error("expected an X row");
606
- const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
607
- if (!gutter) throw new Error("expected a keyframe gutter on the X row");
608
- // At currentPct=0 (playhead on the 0% keyframe) the prev arrow is
609
- // disabled (no earlier keyframe) and the next arrow seeks to the 50%
610
- // keyframe — exactly the case the coherence bug affected.
611
- const nextArrow = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button")).find(
612
- (b) => b.title === "Next keyframe" && !b.disabled,
613
- );
614
- if (!nextArrow) throw new Error("expected an enabled next-keyframe arrow button");
615
- act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
539
+ const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
540
+ (el) => el.querySelector("span")?.textContent === "X",
541
+ );
542
+ if (!xRow) throw new Error("expected an X row");
543
+ const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
544
+ if (!gutter) throw new Error("expected a keyframe gutter on the X row");
545
+ // At currentPct=0 (playhead on the 0% keyframe) the prev arrow is
546
+ // disabled (no earlier keyframe) and the next arrow seeks to the 50%
547
+ // keyframe — exactly the case the coherence bug affected.
548
+ const nextArrow = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button")).find(
549
+ (b) => b.title === "Next keyframe" && !b.disabled,
550
+ );
551
+ if (!nextArrow) throw new Error("expected an enabled next-keyframe arrow button");
552
+ act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
616
553
 
617
- // Same basis as the Timing row: start 2 + 50% * duration 3 = 3.5.
618
- expect(onSeekToTime).toHaveBeenCalledWith(3.5);
619
- act(() => root.unmount());
620
- },
621
- RENDER_TIMEOUT_MS,
622
- );
554
+ // Same basis as the Timing row: start 2 + 50% * duration 3 = 3.5.
555
+ expect(onSeekToTime).toHaveBeenCalledWith(3.5);
556
+ act(() => root.unmount());
557
+ });
623
558
  });
624
559
 
625
560
  // Follow-up fix (review of 684ec4e87): the seek-basis fix above corrected
@@ -631,80 +566,67 @@ describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan cohe
631
566
  // absolute time of the 50% keyframe (2 + 0.5*3 = 3.5) and confirm its diamond
632
567
  // renders "active" (title="Remove x keyframe"), not "inactive"/"ghost".
633
568
  describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up fix)", () => {
634
- it(
635
- "lights the X-row keyframe diamond as active when the playhead is seeked to that keyframe's real absolute time (inferred 2s-5s range, 50% keyframe = 3.5s)",
636
- async () => {
637
- const { host, root } = await renderPanel(
638
- true,
639
- inferredMotionElement(),
640
- { gsapAnimations: [INFERRED_TIMING_ANIMATION] },
641
- { currentTime: 3.5 },
642
- );
643
- openFlatGroup(host, "Layout");
644
- const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
645
- if (!layoutGroup) throw new Error("expected the Layout group to be open");
569
+ it("lights the X-row keyframe diamond as active when the playhead is seeked to that keyframe's real absolute time (inferred 2s-5s range, 50% keyframe = 3.5s)", async () => {
570
+ const { host, root } = await renderPanel(
571
+ true,
572
+ inferredMotionElement(),
573
+ { gsapAnimations: [INFERRED_TIMING_ANIMATION] },
574
+ { currentTime: 3.5 },
575
+ );
576
+ openFlatGroup(host, "Layout");
577
+ const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
578
+ if (!layoutGroup) throw new Error("expected the Layout group to be open");
646
579
 
647
- const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
648
- (el) => el.querySelector("span")?.textContent === "X",
649
- );
650
- if (!xRow) throw new Error("expected an X row");
651
- const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
652
- if (!gutter) throw new Error("expected a keyframe gutter on the X row");
653
- // The diamond is the only gutter button that reports a pressed state;
654
- // the prev/next arrows carry titles too, so `button[title]` is ambiguous.
655
- const diamond = gutter.querySelector<HTMLButtonElement>("button[aria-pressed]");
656
- if (!diamond) throw new Error("expected a keyframe diamond button");
657
- // KeyframeDiamond's title mapping: active -> "Remove ... keyframe",
658
- // inactive -> "Add ... keyframe", ghost -> "Convert ... to keyframes".
659
- // Before this fix, currentPct was computed against the naive
660
- // elStart=0/elDuration=1 basis, so t=3.5 produced currentPct=350% —
661
- // nowhere near the 50% keyframe within KeyframeNavigation's tolerance —
662
- // and the diamond stayed "inactive" even though the playhead was
663
- // exactly on that keyframe's real time.
664
- expect(diamond.title).toBe("Remove x keyframe");
665
- act(() => root.unmount());
666
- },
667
- RENDER_TIMEOUT_MS,
668
- );
580
+ const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
581
+ (el) => el.querySelector("span")?.textContent === "X",
582
+ );
583
+ if (!xRow) throw new Error("expected an X row");
584
+ const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
585
+ if (!gutter) throw new Error("expected a keyframe gutter on the X row");
586
+ // The diamond is the only gutter button that reports a pressed state;
587
+ // the prev/next arrows carry titles too, so `button[title]` is ambiguous.
588
+ const diamond = gutter.querySelector<HTMLButtonElement>("button[aria-pressed]");
589
+ if (!diamond) throw new Error("expected a keyframe diamond button");
590
+ // "Remove" means active: on the naive 0..1 basis t=3.5 read as 350% and the diamond stayed
591
+ // "Add" although the playhead sat on that keyframe.
592
+ expect(diamond.title).toBe("Remove x keyframe");
593
+ act(() => root.unmount());
594
+ });
669
595
 
670
- it(
671
- "prev/next arrows re-center on the current keyframe once currentPct agrees with the corrected seek basis",
672
- async () => {
673
- const onSeekToTime = vi.fn();
674
- const { host, root } = await renderPanel(
675
- true,
676
- inferredMotionElement(),
677
- { gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
678
- { currentTime: 3.5 },
679
- );
680
- openFlatGroup(host, "Layout");
681
- const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
682
- if (!layoutGroup) throw new Error("expected the Layout group to be open");
596
+ it("prev/next arrows re-center on the current keyframe once currentPct agrees with the corrected seek basis", async () => {
597
+ const onSeekToTime = vi.fn();
598
+ const { host, root } = await renderPanel(
599
+ true,
600
+ inferredMotionElement(),
601
+ { gsapAnimations: [INFERRED_TIMING_ANIMATION], onSeekToTime },
602
+ { currentTime: 3.5 },
603
+ );
604
+ openFlatGroup(host, "Layout");
605
+ const layoutGroup = host.querySelector('[data-flat-group-open="true"]');
606
+ if (!layoutGroup) throw new Error("expected the Layout group to be open");
683
607
 
684
- const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
685
- (el) => el.querySelector("span")?.textContent === "X",
686
- );
687
- if (!xRow) throw new Error("expected an X row");
688
- const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
689
- if (!gutter) throw new Error("expected a keyframe gutter on the X row");
690
- const buttons = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button"));
691
- const [prevArrow, , nextArrow] = buttons;
692
- if (!prevArrow || !nextArrow) throw new Error("expected prev/next arrow buttons");
693
- // At the 50% keyframe (t=3.5), prev should target the 0% keyframe
694
- // (absolute t=2) and next should target the 100% keyframe (absolute
695
- // t=5) — both only resolvable once currentPct agrees with elStart=2/
696
- // elDuration=3, the same basis the seek fix already uses.
697
- expect(prevArrow.disabled).toBe(false);
698
- act(() => prevArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
699
- expect(onSeekToTime).toHaveBeenLastCalledWith(2);
700
-
701
- expect(nextArrow.disabled).toBe(false);
702
- act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
703
- expect(onSeekToTime).toHaveBeenLastCalledWith(5);
704
- act(() => root.unmount());
705
- },
706
- RENDER_TIMEOUT_MS,
707
- );
608
+ const xRow = Array.from(layoutGroup.querySelectorAll<HTMLElement>(".group")).find(
609
+ (el) => el.querySelector("span")?.textContent === "X",
610
+ );
611
+ if (!xRow) throw new Error("expected an X row");
612
+ const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
613
+ if (!gutter) throw new Error("expected a keyframe gutter on the X row");
614
+ const buttons = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button"));
615
+ const [prevArrow, , nextArrow] = buttons;
616
+ if (!prevArrow || !nextArrow) throw new Error("expected prev/next arrow buttons");
617
+ // At the 50% keyframe (t=3.5), prev should target the 0% keyframe
618
+ // (absolute t=2) and next should target the 100% keyframe (absolute
619
+ // t=5) — both only resolvable once currentPct agrees with elStart=2/
620
+ // elDuration=3, the same basis the seek fix already uses.
621
+ expect(prevArrow.disabled).toBe(false);
622
+ act(() => prevArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
623
+ expect(onSeekToTime).toHaveBeenLastCalledWith(2);
624
+
625
+ expect(nextArrow.disabled).toBe(false);
626
+ act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
627
+ expect(onSeekToTime).toHaveBeenLastCalledWith(5);
628
+ act(() => root.unmount());
629
+ });
708
630
  });
709
631
 
710
632
  // Media fixtures (Plan 4 Task 7): the three tag values resolveEditingSections
@@ -760,126 +682,97 @@ function flatGroupTitles(host: HTMLElement): string[] {
760
682
  }
761
683
 
762
684
  describe("PropertyPanel — Grade group (flag on)", () => {
763
- it(
764
- "renders the Grade group with its accessory for a grade-editable (video) element",
765
- async () => {
766
- const { host, root } = await renderPanel(true, {
767
- ...baseElement(),
768
- tagName: "video",
769
- textFields: [],
770
- });
771
- const gradeCollapsedOrOpen =
772
- host.querySelector('[data-flat-group-collapsed="true"]') ||
773
- host.querySelector('[data-flat-group-open="true"]');
774
- expect(host.textContent).toContain("Grade");
775
- expect(gradeCollapsedOrOpen).not.toBeNull();
776
- act(() => root.unmount());
777
- },
778
- RENDER_TIMEOUT_MS,
779
- );
685
+ it("renders the Grade group with its accessory for a grade-editable (video) element", async () => {
686
+ const { host, root } = await renderPanel(true, {
687
+ ...baseElement(),
688
+ tagName: "video",
689
+ textFields: [],
690
+ });
691
+ const gradeCollapsedOrOpen =
692
+ host.querySelector('[data-flat-group-collapsed="true"]') ||
693
+ host.querySelector('[data-flat-group-open="true"]');
694
+ expect(host.textContent).toContain("Grade");
695
+ expect(gradeCollapsedOrOpen).not.toBeNull();
696
+ act(() => root.unmount());
697
+ });
780
698
 
781
- it(
782
- "does not render the legacy Style/Grade Section duplicates for a style-and-grade-editable element",
783
- async () => {
784
- // A <video> with no text fields is both style-editable (inherited
785
- // capabilities.canEditStyles: true) and grade-editable (tag === "video"),
786
- // so both the flat Style and Grade groups render — the exact shape that
787
- // used to also mount the legacy ColorGradingSection + StyleSections below
788
- // them (the hybrid-duplication bug this task retires).
789
- const { host, root } = await renderPanel(true, {
790
- ...baseElement(),
791
- tagName: "video",
792
- textFields: [],
793
- });
794
- expect(host.textContent).toContain("Style");
795
- expect(host.textContent).toContain("Grade");
796
- // The legacy `Section` primitive (propertyPanelStyleSections.tsx /
797
- // propertyPanelColorGradingSection.tsx) renders a `<section
798
- // data-panel-section="<slugified-title>">` for each of its sections. A
799
- // bare textContent check can't tell a legacy Section title apart from a
800
- // flat row label with the same word — "Fill" is both the legacy Fill
801
- // `Section` title AND a row label inside FlatStyleSection, which is
802
- // supposed to still be there — so assert on the legacy Section's actual
803
- // DOM shape instead of a substring match.
804
- for (const slug of [
805
- "radius",
806
- "stroke",
807
- "effects",
808
- "clip",
809
- "transparency",
810
- "fill",
811
- "color-grading",
812
- ]) {
813
- expect(host.querySelector(`[data-panel-section="${slug}"]`)).toBeNull();
814
- }
815
- act(() => root.unmount());
816
- },
817
- RENDER_TIMEOUT_MS,
818
- );
699
+ it("does not render the legacy Style/Grade Section duplicates for a style-and-grade-editable element", async () => {
700
+ // A <video> with no text fields is both style- and grade-editable: the shape that also
701
+ // mounted the legacy ColorGradingSection and StyleSections below the flat groups.
702
+ const { host, root } = await renderPanel(true, {
703
+ ...baseElement(),
704
+ tagName: "video",
705
+ textFields: [],
706
+ });
707
+ expect(host.textContent).toContain("Style");
708
+ expect(host.textContent).toContain("Grade");
709
+ // Each legacy Section renders `<section data-panel-section="<slug>">`; text alone cannot tell
710
+ // it from a flat row of the same name ("Fill" is both), so match that shape.
711
+ for (const slug of [
712
+ "radius",
713
+ "stroke",
714
+ "effects",
715
+ "clip",
716
+ "transparency",
717
+ "fill",
718
+ "color-grading",
719
+ ]) {
720
+ expect(host.querySelector(`[data-panel-section="${slug}"]`)).toBeNull();
721
+ }
722
+ act(() => root.unmount());
723
+ });
819
724
  });
820
725
 
821
726
  describe("PropertyPanel — Media group (Plan 4)", () => {
822
- it(
823
- "renders the flat Media group and not the legacy MediaSection, for a video element",
824
- async () => {
825
- const { host, root } = await renderPanel(true, videoElement() as never);
826
- // A Media FlatGroup exists (open or collapsed).
827
- expect(flatGroupTitles(host)).toContain("Media");
828
- // The legacy MediaSection renders its rows inside a `Section` whose
829
- // data-panel-section slug is the media title ("video"/"image"/"audio").
830
- // On the flat path it's fully replaced, so none of those may appear.
831
- expect(host.querySelector('[data-panel-section="video"]')).toBeNull();
832
- expect(host.querySelector('[data-panel-section="image"]')).toBeNull();
833
- expect(host.querySelector('[data-panel-section="audio"]')).toBeNull();
834
- act(() => root.unmount());
835
- },
836
- RENDER_TIMEOUT_MS,
837
- );
727
+ it("renders the flat Media group and not the legacy MediaSection, for a video element", async () => {
728
+ const { host, root } = await renderPanel(true, videoElement() as never);
729
+ // A Media FlatGroup exists (open or collapsed).
730
+ expect(flatGroupTitles(host)).toContain("Media");
731
+ // The legacy MediaSection renders its rows inside a `Section` whose
732
+ // data-panel-section slug is the media title ("video"/"image"/"audio").
733
+ // On the flat path it's fully replaced, so none of those may appear.
734
+ expect(host.querySelector('[data-panel-section="video"]')).toBeNull();
735
+ expect(host.querySelector('[data-panel-section="image"]')).toBeNull();
736
+ expect(host.querySelector('[data-panel-section="audio"]')).toBeNull();
737
+ act(() => root.unmount());
738
+ });
838
739
 
839
- it(
840
- "one-open accordion: opening Media closes whichever other group was open, and vice versa (5-way exclusivity)",
841
- async () => {
842
- // videoElement() has canEditStyles: true and no text fields, so Style is
843
- // the default-open group; Layout and Media render collapsed alongside it.
844
- const { host, root } = await renderPanel(true, videoElement() as never);
845
- expect(openGroupText(host)).toContain("Style");
846
-
847
- // Opening Media closes Style.
848
- openFlatGroup(host, "Media");
849
- const afterMedia = openGroupText(host);
850
- expect(afterMedia).toContain("Media");
851
- expect(afterMedia).not.toContain("Style");
852
-
853
- // Reverse direction: opening Layout closes Media — same shared openGroupId.
854
- openFlatGroup(host, "Layout");
855
- const afterLayout = openGroupText(host);
856
- expect(afterLayout).toContain("Layout");
857
- expect(afterLayout).not.toContain("Media");
858
- act(() => root.unmount());
859
- },
860
- RENDER_TIMEOUT_MS,
861
- );
740
+ it("one-open accordion: opening Media closes whichever other group was open, and vice versa (5-way exclusivity)", async () => {
741
+ // videoElement() has canEditStyles: true and no text fields, so Style is
742
+ // the default-open group; Layout and Media render collapsed alongside it.
743
+ const { host, root } = await renderPanel(true, videoElement() as never);
744
+ expect(openGroupText(host)).toContain("Style");
745
+
746
+ // Opening Media closes Style.
747
+ openFlatGroup(host, "Media");
748
+ const afterMedia = openGroupText(host);
749
+ expect(afterMedia).toContain("Media");
750
+ expect(afterMedia).not.toContain("Style");
751
+
752
+ // Reverse direction: opening Layout closes Media — same shared openGroupId.
753
+ openFlatGroup(host, "Layout");
754
+ const afterLayout = openGroupText(host);
755
+ expect(afterLayout).toContain("Layout");
756
+ expect(afterLayout).not.toContain("Media");
757
+ act(() => root.unmount());
758
+ });
862
759
 
863
- it(
864
- "gates the Media group exactly like the legacy MediaSection: present for video/img/audio, absent for a plain div/text element",
865
- async () => {
866
- for (const fixture of [videoElement, imageElement, audioElement]) {
867
- const { host, root } = await renderPanel(true, fixture() as never);
868
- expect(flatGroupTitles(host)).toContain("Media");
869
- act(() => root.unmount());
870
- }
871
-
872
- // baseElement() is a plain <div> with text — sections.media is false, so
873
- // no Media group (flat or legacy) may render.
874
- const { host, root } = await renderPanel(true);
875
- expect(flatGroupTitles(host)).not.toContain("Media");
876
- expect(host.querySelector('[data-panel-section="video"]')).toBeNull();
877
- expect(host.querySelector('[data-panel-section="image"]')).toBeNull();
878
- expect(host.querySelector('[data-panel-section="audio"]')).toBeNull();
760
+ it("gates the Media group exactly like the legacy MediaSection: present for video/img/audio, absent for a plain div/text element", async () => {
761
+ for (const fixture of [videoElement, imageElement, audioElement]) {
762
+ const { host, root } = await renderPanel(true, fixture() as never);
763
+ expect(flatGroupTitles(host)).toContain("Media");
879
764
  act(() => root.unmount());
880
- },
881
- RENDER_TIMEOUT_MS,
882
- );
765
+ }
766
+
767
+ // baseElement() is a plain <div> with text — sections.media is false, so
768
+ // no Media group (flat or legacy) may render.
769
+ const { host, root } = await renderPanel(true);
770
+ expect(flatGroupTitles(host)).not.toContain("Media");
771
+ expect(host.querySelector('[data-panel-section="video"]')).toBeNull();
772
+ expect(host.querySelector('[data-panel-section="image"]')).toBeNull();
773
+ expect(host.querySelector('[data-panel-section="audio"]')).toBeNull();
774
+ act(() => root.unmount());
775
+ });
883
776
  });
884
777
 
885
778
  // design_handoff scrollable-open-section: collapsed headers before/after the
@@ -891,135 +784,118 @@ describe("PropertyPanel — Media group (Plan 4)", () => {
891
784
  // renders as an open header + scrollable body, grade/effects/media render as fixed
892
785
  // collapsed headers after it — in exactly that DOM order, nothing sticky.
893
786
  describe("PropertyPanel — fixed headers + scrollable open section (Plan 11)", () => {
894
- it(
895
- "renders before-open headers, the open group (header + scrollable body), then after-open headers, in that exact order, with nothing sticky",
896
- async () => {
897
- const { host, root } = await renderPanel(true, sixGroupElement());
898
- // sixGroupElement() opens Text by default; open Motion (index 3) to
899
- // match the worked example.
900
- openFlatGroup(host, "Motion");
901
- expect(openGroupText(host)).toContain("Motion");
902
-
903
- const body = host.querySelector('[data-flat-panel-body="true"]');
904
- if (!body) throw new Error("expected the flat panel body container");
905
-
906
- // Titles in DOM order: each child is either a collapsed header button
907
- // (before/after the open group) or the open-group wrapper div.
908
- const titles = Array.from(body.children).map((child) => {
909
- if (child.matches('[data-flat-group-collapsed="true"]')) return child.textContent ?? "";
910
- if (child.matches('[data-flat-group-open="true"]')) return child.textContent ?? "";
911
- return null;
912
- });
913
- // Filter to just the group entries (drop any non-group nulls).
914
- const groupTitles = titles.filter((t): t is string => t !== null);
915
- expect(groupTitles).toHaveLength(7);
916
- expect(groupTitles[0]).toContain("Text");
917
- expect(groupTitles[1]).toContain("Style");
918
- expect(groupTitles[2]).toContain("Layout");
919
- expect(groupTitles[3]).toContain("Motion");
920
- expect(groupTitles[4]).toContain("Grade");
921
- expect(groupTitles[5]).toContain("Effects");
922
- expect(groupTitles[6]).toContain("Media");
923
-
924
- // The open group (Motion, index 3) is the one wrapped in
925
- // data-flat-group-open, sitting between the before/after collapsed
926
- // headers — and it must contain a dedicated scrollable body.
927
- const openWrapper = host.querySelector('[data-flat-group-open="true"]');
928
- if (!openWrapper) throw new Error("expected the open-group wrapper");
929
- expect(openWrapper.textContent).toContain("Motion");
930
- expect(openWrapper.querySelector(".overflow-y-auto")).not.toBeNull();
931
-
932
- // Nothing anywhere in the panel body carries inline sticky positioning
933
- // — the entire sticky-stacking mechanism is gone.
934
- const stickyEls = Array.from(body.querySelectorAll<HTMLElement>("[style]")).filter(
935
- (el) => el.style.position === "sticky",
936
- );
937
- expect(stickyEls).toHaveLength(0);
938
- act(() => root.unmount());
939
- },
940
- RENDER_TIMEOUT_MS,
941
- );
787
+ it("renders before-open headers, the open group (header + scrollable body), then after-open headers, in that exact order, with nothing sticky", async () => {
788
+ const { host, root } = await renderPanel(true, sixGroupElement());
789
+ // sixGroupElement() opens Text by default; open Motion (index 3) to
790
+ // match the worked example.
791
+ openFlatGroup(host, "Motion");
792
+ expect(openGroupText(host)).toContain("Motion");
793
+
794
+ const body = host.querySelector('[data-flat-panel-body="true"]');
795
+ if (!body) throw new Error("expected the flat panel body container");
796
+
797
+ // Titles in DOM order: each child is either a collapsed header button
798
+ // (before/after the open group) or the open-group wrapper div.
799
+ const titles = Array.from(body.children).map((child) => {
800
+ if (child.matches('[data-flat-group-collapsed="true"]')) return child.textContent ?? "";
801
+ if (child.matches('[data-flat-group-open="true"]')) return child.textContent ?? "";
802
+ return null;
803
+ });
804
+ // Filter to just the group entries (drop any non-group nulls).
805
+ const groupTitles = titles.filter((t): t is string => t !== null);
806
+ expect(groupTitles).toHaveLength(7);
807
+ expect(groupTitles[0]).toContain("Text");
808
+ expect(groupTitles[1]).toContain("Style");
809
+ expect(groupTitles[2]).toContain("Layout");
810
+ expect(groupTitles[3]).toContain("Motion");
811
+ expect(groupTitles[4]).toContain("Grade");
812
+ expect(groupTitles[5]).toContain("Effects");
813
+ expect(groupTitles[6]).toContain("Media");
814
+
815
+ // The open group (Motion, index 3) is the one wrapped in
816
+ // data-flat-group-open, sitting between the before/after collapsed
817
+ // headers — and it must contain a dedicated scrollable body.
818
+ const openWrapper = host.querySelector('[data-flat-group-open="true"]');
819
+ if (!openWrapper) throw new Error("expected the open-group wrapper");
820
+ expect(openWrapper.textContent).toContain("Motion");
821
+ expect(openWrapper.querySelector(".overflow-y-auto")).not.toBeNull();
822
+
823
+ // Nothing anywhere in the panel body carries inline sticky positioning
824
+ // — the entire sticky-stacking mechanism is gone.
825
+ const stickyEls = Array.from(body.querySelectorAll<HTMLElement>("[style]")).filter(
826
+ (el) => el.style.position === "sticky",
827
+ );
828
+ expect(stickyEls).toHaveLength(0);
829
+ act(() => root.unmount());
830
+ });
942
831
 
943
- it(
944
- "renders every group as a plain collapsed header with no scrollable middle region when nothing is open",
945
- async () => {
946
- const { host, root } = await renderPanel(true, sixGroupElement());
947
- // Collapse the default-open Text group so openGroupId becomes "".
948
- const collapseButton = host.querySelector<HTMLButtonElement>(
949
- '[data-flat-group-open="true"] button[title="Collapse"]',
950
- );
951
- act(() => collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
952
- expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull();
832
+ it("renders every group as a plain collapsed header with no scrollable middle region when nothing is open", async () => {
833
+ const { host, root } = await renderPanel(true, sixGroupElement());
834
+ // Collapse the default-open Text group so openGroupId becomes "".
835
+ const collapseButton = host.querySelector<HTMLButtonElement>(
836
+ '[data-flat-group-open="true"] button[title="Collapse"]',
837
+ );
838
+ act(() => collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
839
+ expect(host.querySelector('[data-flat-group-open="true"]')).toBeNull();
953
840
 
954
- const collapsedRows = Array.from(
955
- host.querySelectorAll<HTMLButtonElement>('[data-flat-group-collapsed="true"]'),
956
- );
957
- expect(collapsedRows).toHaveLength(7);
958
- const titlesInOrder = collapsedRows.map((el) => el.textContent ?? "");
959
- expect(titlesInOrder[0]).toContain("Text");
960
- expect(titlesInOrder[1]).toContain("Style");
961
- expect(titlesInOrder[2]).toContain("Layout");
962
- expect(titlesInOrder[3]).toContain("Motion");
963
- expect(titlesInOrder[4]).toContain("Grade");
964
- expect(titlesInOrder[5]).toContain("Effects");
965
- expect(titlesInOrder[6]).toContain("Media");
966
-
967
- const body = host.querySelector('[data-flat-panel-body="true"]');
968
- expect(body?.querySelector(".overflow-y-auto")).toBeNull();
969
- for (const row of collapsedRows) {
970
- expect(row.style.position).toBe("");
971
- }
972
- act(() => root.unmount());
973
- },
974
- RENDER_TIMEOUT_MS,
975
- );
841
+ const collapsedRows = Array.from(
842
+ host.querySelectorAll<HTMLButtonElement>('[data-flat-group-collapsed="true"]'),
843
+ );
844
+ expect(collapsedRows).toHaveLength(7);
845
+ const titlesInOrder = collapsedRows.map((el) => el.textContent ?? "");
846
+ expect(titlesInOrder[0]).toContain("Text");
847
+ expect(titlesInOrder[1]).toContain("Style");
848
+ expect(titlesInOrder[2]).toContain("Layout");
849
+ expect(titlesInOrder[3]).toContain("Motion");
850
+ expect(titlesInOrder[4]).toContain("Grade");
851
+ expect(titlesInOrder[5]).toContain("Effects");
852
+ expect(titlesInOrder[6]).toContain("Media");
853
+
854
+ const body = host.querySelector('[data-flat-panel-body="true"]');
855
+ expect(body?.querySelector(".overflow-y-auto")).toBeNull();
856
+ for (const row of collapsedRows) {
857
+ expect(row.style.position).toBe("");
858
+ }
859
+ act(() => root.unmount());
860
+ });
976
861
  });
977
862
 
978
863
  describe("PropertyPanel — flat group entrance animation scoping (fix round)", () => {
979
- it(
980
- "animates only the opening group and the implicitly-closed group on a non-adjacent toggle, never untouched siblings",
981
- async () => {
982
- const { host, root } = await renderPanel(true, sixGroupElement());
983
- // sixGroupElement() opens Text by default; jump straight to Motion
984
- // (skipping over Style/Layout) first, matching the Plan 11 worked
985
- // example, then jump back to Text — non-adjacent from Motion, again
986
- // skipping over Style/Layout. This is the exact array-slice-position-
987
- // shift scenario the justToggledIds mechanism exists to guard: Style
988
- // and Layout shift position in the before/after-open slices on both
989
- // toggles even though neither of them is the group being toggled.
990
- openFlatGroup(host, "Motion");
991
- expect(openGroupText(host)).toContain("Motion");
992
- openFlatGroup(host, "Text");
993
- expect(openGroupText(host)).toContain("Text");
994
-
995
- const collapsedRowByTitle = (title: string) => {
996
- const row = Array.from(host.querySelectorAll('[data-flat-group-collapsed="true"]')).find(
997
- (el) => el.textContent?.includes(title),
998
- );
999
- if (!row) throw new Error(`expected a collapsed ${title} row`);
1000
- return row;
1001
- };
1002
-
1003
- // Untouched, non-adjacent siblings must NOT receive the entrance class,
1004
- // even though they shifted position in the collapsed-header list.
1005
- expect(collapsedRowByTitle("Style").classList.contains("hf-flat-group-enter")).toBe(false);
1006
- expect(collapsedRowByTitle("Layout").classList.contains("hf-flat-group-enter")).toBe(false);
1007
- expect(collapsedRowByTitle("Grade").classList.contains("hf-flat-group-enter")).toBe(false);
1008
- expect(collapsedRowByTitle("Media").classList.contains("hf-flat-group-enter")).toBe(false);
1009
-
1010
- // Motion — open a moment ago, just implicitly closed by the click on
1011
- // Text — must still play its own collapse-entrance animation (Finding 1).
1012
- expect(collapsedRowByTitle("Motion").classList.contains("hf-flat-group-enter")).toBe(true);
1013
-
1014
- // Text — the group actually clicked open — must animate too.
1015
- const openWrapper = host.querySelector('[data-flat-group-open="true"]');
1016
- if (!openWrapper) throw new Error("expected the open-group wrapper");
1017
- expect(openWrapper.querySelector(".hf-flat-group-enter")).not.toBeNull();
1018
-
1019
- act(() => root.unmount());
1020
- },
1021
- RENDER_TIMEOUT_MS,
1022
- );
864
+ it("animates only the opening group and the implicitly-closed group on a non-adjacent toggle, never untouched siblings", async () => {
865
+ const { host, root } = await renderPanel(true, sixGroupElement());
866
+ // Text opens by default; jumping to Motion and back skips Style and Layout both ways, so they
867
+ // shift position in the open slices without being toggled: what justToggledIds guards.
868
+ openFlatGroup(host, "Motion");
869
+ expect(openGroupText(host)).toContain("Motion");
870
+ openFlatGroup(host, "Text");
871
+ expect(openGroupText(host)).toContain("Text");
872
+
873
+ const collapsedRowByTitle = (title: string) => {
874
+ const row = Array.from(host.querySelectorAll('[data-flat-group-collapsed="true"]')).find(
875
+ (el) => el.textContent?.includes(title),
876
+ );
877
+ if (!row) throw new Error(`expected a collapsed ${title} row`);
878
+ return row;
879
+ };
880
+
881
+ // Untouched, non-adjacent siblings must NOT receive the entrance class,
882
+ // even though they shifted position in the collapsed-header list.
883
+ expect(collapsedRowByTitle("Style").classList.contains("hf-flat-group-enter")).toBe(false);
884
+ expect(collapsedRowByTitle("Layout").classList.contains("hf-flat-group-enter")).toBe(false);
885
+ expect(collapsedRowByTitle("Grade").classList.contains("hf-flat-group-enter")).toBe(false);
886
+ expect(collapsedRowByTitle("Media").classList.contains("hf-flat-group-enter")).toBe(false);
887
+
888
+ // Motion — open a moment ago, just implicitly closed by the click on
889
+ // Text — must still play its own collapse-entrance animation (Finding 1).
890
+ expect(collapsedRowByTitle("Motion").classList.contains("hf-flat-group-enter")).toBe(true);
891
+
892
+ // Text — the group actually clicked open — must animate too.
893
+ const openWrapper = host.querySelector('[data-flat-group-open="true"]');
894
+ if (!openWrapper) throw new Error("expected the open-group wrapper");
895
+ expect(openWrapper.querySelector(".hf-flat-group-enter")).not.toBeNull();
896
+
897
+ act(() => root.unmount());
898
+ });
1023
899
  });
1024
900
 
1025
901
  describe("PropertyPanel — Motion is for things that move", () => {
@@ -1033,148 +909,124 @@ describe("PropertyPanel — Motion is for things that move", () => {
1033
909
  return element;
1034
910
  },
1035
911
  ],
1036
- ])(
1037
- "recognizes %s through the shared audio predicate",
1038
- async (_label, makeElement) => {
1039
- const fixture = {
1040
- ...audioClipElement(),
1041
- element: makeElement(),
1042
- tagName: "div",
1043
- };
1044
- const { host, root } = await renderPanel(true, fixture);
1045
- const titles = Array.from(
1046
- host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
1047
- ).map((node) => node.textContent ?? "");
1048
- expect(titles.some((title) => title.includes("Motion"))).toBe(false);
1049
- expect(titles.some((title) => title.includes("Timing"))).toBe(true);
1050
- act(() => root.unmount());
1051
- },
1052
- RENDER_TIMEOUT_MS,
1053
- );
912
+ ])("recognizes %s through the shared audio predicate", async (_label, makeElement) => {
913
+ const fixture = {
914
+ ...audioClipElement(),
915
+ element: makeElement(),
916
+ tagName: "div",
917
+ };
918
+ const { host, root } = await renderPanel(true, fixture);
919
+ const titles = Array.from(
920
+ host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
921
+ ).map((node) => node.textContent ?? "");
922
+ expect(titles.some((title) => title.includes("Motion"))).toBe(false);
923
+ expect(titles.some((title) => title.includes("Timing"))).toBe(true);
924
+ act(() => root.unmount());
925
+ });
1054
926
 
1055
- it(
1056
- "calls the section Timing on an audio clip, and offers no tween editor",
1057
- async () => {
1058
- const { host, root } = await renderPanel(true, audioClipElement());
1059
- const titles = Array.from(
1060
- host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
1061
- ).map((el) => el.textContent ?? "");
1062
- // The clip's placement survives — it is still a clip on a track.
1063
- expect(titles.some((t) => t.includes("Timing"))).toBe(true);
1064
- // "Motion" named the tween editor, which an <audio> element has no
1065
- // transform, opacity or box for. Showing it was the panel gating on
1066
- // handler presence rather than on the element.
1067
- expect(titles.some((t) => t.includes("Motion"))).toBe(false);
1068
- act(() => root.unmount());
1069
- },
1070
- RENDER_TIMEOUT_MS,
1071
- );
927
+ it("calls the section Timing on an audio clip, and offers no tween editor", async () => {
928
+ const { host, root } = await renderPanel(true, audioClipElement());
929
+ const titles = Array.from(
930
+ host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
931
+ ).map((el) => el.textContent ?? "");
932
+ // The clip's placement survives — it is still a clip on a track.
933
+ expect(titles.some((t) => t.includes("Timing"))).toBe(true);
934
+ // "Motion" named the tween editor, which an <audio> element has no
935
+ // transform, opacity or box for. Showing it was the panel gating on
936
+ // handler presence rather than on the element.
937
+ expect(titles.some((t) => t.includes("Motion"))).toBe(false);
938
+ act(() => root.unmount());
939
+ });
1072
940
 
1073
- it(
1074
- "offers a mute on an audible audio clip and toggles it through the timeline",
1075
- async () => {
1076
- const onToggleElementHidden = vi.fn();
1077
- const { host, root } = await renderPanel(
1078
- true,
1079
- audioClipElement(),
1080
- { onToggleElementHidden },
1081
- { selectedElementId: "vo-1" },
1082
- );
1083
- act(() => host.querySelector<HTMLElement>('button[aria-label="Mute element"]')!.click());
1084
- expect(onToggleElementHidden).toHaveBeenCalledWith("vo-1", true);
1085
- act(() => root.unmount());
1086
- },
1087
- RENDER_TIMEOUT_MS,
1088
- );
941
+ it("offers a mute on an audible audio clip and toggles it through the timeline", async () => {
942
+ const onToggleElementHidden = vi.fn();
943
+ const { host, root } = await renderPanel(
944
+ true,
945
+ audioClipElement(),
946
+ { onToggleElementHidden },
947
+ { selectedElementId: "vo-1" },
948
+ );
949
+ act(() => host.querySelector<HTMLElement>('button[aria-label="Mute element"]')!.click());
950
+ expect(onToggleElementHidden).toHaveBeenCalledWith("vo-1", true);
951
+ act(() => root.unmount());
952
+ });
1089
953
 
1090
954
  // A bus has no timeline row, so the panel is its only mute; it must go both ways.
1091
- it(
1092
- "mutes, unmutes and mutes an audio group from the panel header",
1093
- async () => {
1094
- const bus = (hidden: boolean) => {
1095
- const element = audioBusElement();
1096
- return {
1097
- ...element,
1098
- dataAttributes: { ...element.dataAttributes, ...(hidden ? { hidden: "" } : {}) },
1099
- };
1100
- };
1101
- let hidden = true;
1102
- const onSetAudioGroupAttributeQuiet = vi.fn(
1103
- async (_id: string, _attr: string, value: string | null, _label: string) => {
1104
- hidden = value !== null;
1105
- },
1106
- );
1107
- // The panel re-reads its selection after the write; nothing else re-renders it here.
1108
- const { host, root, render } = await renderPanel(
1109
- true,
1110
- bus(true),
1111
- {},
1112
- {
1113
- timelineEdit: { onSetAudioGroupAttributeQuiet },
1114
- selectionRef: { current: bus(true) },
1115
- refreshSelection: () => render(bus(hidden)),
1116
- },
1117
- );
1118
- const press = async (label: string) => {
1119
- const button = host.querySelector<HTMLElement>(`button[aria-label="${label}"]`);
1120
- if (!button) throw new Error(`Expected a "${label}" button`);
1121
- await act(async () => button.click());
955
+ it("mutes, unmutes and mutes an audio group from the panel header", async () => {
956
+ const bus = (hidden: boolean) => {
957
+ const element = audioBusElement();
958
+ return {
959
+ ...element,
960
+ dataAttributes: { ...element.dataAttributes, ...(hidden ? { hidden: "" } : {}) },
1122
961
  };
1123
- await press("Unmute element");
1124
- await press("Mute element");
1125
- await press("Unmute element");
1126
- expect(onSetAudioGroupAttributeQuiet.mock.calls).toEqual([
1127
- ["voiceover", "data-hidden", null, "Unmute element"],
1128
- ["voiceover", "data-hidden", "", "Mute element"],
1129
- ["voiceover", "data-hidden", null, "Unmute element"],
1130
- ]);
1131
- act(() => root.unmount());
1132
- },
1133
- RENDER_TIMEOUT_MS,
1134
- );
962
+ };
963
+ let hidden = true;
964
+ const onSetAudioGroupAttributeQuiet = vi.fn(
965
+ async (_id: string, _attr: string, value: string | null, _label: string) => {
966
+ hidden = value !== null;
967
+ },
968
+ );
969
+ // The panel re-reads its selection after the write; nothing else re-renders it here.
970
+ const { host, root, render } = await renderPanel(
971
+ true,
972
+ bus(true),
973
+ {},
974
+ {
975
+ timelineEdit: { onSetAudioGroupAttributeQuiet },
976
+ selectionRef: { current: bus(true) },
977
+ refreshSelection: () => render(bus(hidden)),
978
+ },
979
+ );
980
+ const press = async (label: string) => {
981
+ const button = host.querySelector<HTMLElement>(`button[aria-label="${label}"]`);
982
+ if (!button) throw new Error(`Expected a "${label}" button`);
983
+ await act(async () => button.click());
984
+ };
985
+ await press("Unmute element");
986
+ await press("Mute element");
987
+ await press("Unmute element");
988
+ expect(onSetAudioGroupAttributeQuiet.mock.calls).toEqual([
989
+ ["voiceover", "data-hidden", null, "Unmute element"],
990
+ ["voiceover", "data-hidden", "", "Mute element"],
991
+ ["voiceover", "data-hidden", null, "Unmute element"],
992
+ ]);
993
+ act(() => root.unmount());
994
+ });
1135
995
 
1136
- it(
1137
- "re-reads the current selection, not the muted bus, once the write lands",
1138
- async () => {
1139
- let land = () => {};
1140
- const onSetAudioGroupAttributeQuiet = vi.fn(
1141
- () => new Promise<void>((resolve) => (land = resolve)),
1142
- );
1143
- const selectionRef: { current: unknown } = { current: audioBusElement() };
1144
- const refreshSelection = vi.fn();
1145
- const { host, root } = await renderPanel(
1146
- true,
1147
- audioBusElement(),
1148
- {},
1149
- {
1150
- timelineEdit: { onSetAudioGroupAttributeQuiet },
1151
- selectionRef,
1152
- refreshSelection,
1153
- },
1154
- );
1155
- act(() => host.querySelector<HTMLElement>('button[aria-label="Mute element"]')!.click());
1156
- const next = audioClipElement();
1157
- selectionRef.current = next;
1158
- await act(async () => land());
1159
- expect(refreshSelection.mock.calls).toEqual([[next]]);
1160
- act(() => root.unmount());
1161
- },
1162
- RENDER_TIMEOUT_MS,
1163
- );
996
+ it("re-reads the current selection, not the muted bus, once the write lands", async () => {
997
+ let land = () => {};
998
+ const onSetAudioGroupAttributeQuiet = vi.fn(
999
+ () => new Promise<void>((resolve) => (land = resolve)),
1000
+ );
1001
+ const selectionRef: { current: unknown } = { current: audioBusElement() };
1002
+ const refreshSelection = vi.fn();
1003
+ const { host, root } = await renderPanel(
1004
+ true,
1005
+ audioBusElement(),
1006
+ {},
1007
+ {
1008
+ timelineEdit: { onSetAudioGroupAttributeQuiet },
1009
+ selectionRef,
1010
+ refreshSelection,
1011
+ },
1012
+ );
1013
+ act(() => host.querySelector<HTMLElement>('button[aria-label="Mute element"]')!.click());
1014
+ const next = audioClipElement();
1015
+ selectionRef.current = next;
1016
+ await act(async () => land());
1017
+ expect(refreshSelection.mock.calls).toEqual([[next]]);
1018
+ act(() => root.unmount());
1019
+ });
1164
1020
 
1165
- it(
1166
- "offers a bus neither — it has no clip range to edit",
1167
- async () => {
1168
- const { host, root } = await renderPanel(true, audioBusElement());
1169
- const titles = Array.from(
1170
- host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
1171
- ).map((el) => el.textContent ?? "");
1172
- expect(titles.some((t) => t.includes("Motion"))).toBe(false);
1173
- expect(titles.some((t) => t.includes("Timing"))).toBe(false);
1174
- // It is still a mixer bus: the reason to select one at all.
1175
- expect(titles.some((t) => t.includes("Audio FX"))).toBe(true);
1176
- act(() => root.unmount());
1177
- },
1178
- RENDER_TIMEOUT_MS,
1179
- );
1021
+ it("offers a bus neither — it has no clip range to edit", async () => {
1022
+ const { host, root } = await renderPanel(true, audioBusElement());
1023
+ const titles = Array.from(
1024
+ host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
1025
+ ).map((el) => el.textContent ?? "");
1026
+ expect(titles.some((t) => t.includes("Motion"))).toBe(false);
1027
+ expect(titles.some((t) => t.includes("Timing"))).toBe(false);
1028
+ // It is still a mixer bus: the reason to select one at all.
1029
+ expect(titles.some((t) => t.includes("Audio FX"))).toBe(true);
1030
+ act(() => root.unmount());
1031
+ });
1180
1032
  });