Skip to content

feat(studio): add Layers panel as new inspector tab#784

Merged
miguel-heygen merged 10 commits into
mainfrom
feat/layers-panel
May 13, 2026
Merged

feat(studio): add Layers panel as new inspector tab#784
miguel-heygen merged 10 commits into
mainfrom
feat/layers-panel

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen commented May 13, 2026

Summary

  • Adds a dedicated Layers tab to the right panel inspector, alongside Design and Renders
  • Shows the full composition element tree from the composition root with proper indentation and collapsible hierarchy
  • Clicking a layer selects it (highlights in preview + timeline) without navigating away — the full tree stays visible
  • When the Layers tab is active, clicking elements in the preview keeps you on Layers instead of auto-switching to Design

Closes #783

Changes

  • packages/studio/src/components/editor/LayersPanel.tsx — New component that reads the composition DOM from the preview iframe and renders a collapsible tree
  • packages/studio/src/components/StudioRightPanel.tsx — Added Layers tab button and LayersPanel rendering
  • packages/studio/src/utils/studioHelpers.ts — Added "layers" to RightPanelTab type
  • packages/studio/src/hooks/useDomSelection.ts — Selection no longer force-switches to Design when Layers tab is active
  • packages/studio/src/App.tsx — Added layersPanelActive to inspectorPanelActive check

Test plan

  • Open a composition in Studio, click Inspector to open right panel
  • Verify three tabs visible: Layers, Design, Renders
  • Click Layers tab — should show full element tree with tag badges (DI, H1, P, etc.)
  • Click a layer item — should select it (green highlight in tree, selection overlay on preview)
  • Click a different layer — selection moves, tree stays fully visible
  • Click collapse arrow on a parent element — children hide, arrow rotates
  • Click collapse arrow again — children re-appear
  • Click element in preview while on Layers tab — stays on Layers tab, selection updates in tree
  • Switch to Design tab, click element — still opens Design as before
  • Verify existing Design/Motion/Renders functionality unchanged

🤖 Generated with Claude Code

miguel-heygen and others added 8 commits May 12, 2026 23:02
Adds a dedicated Layers tab alongside Design and Renders in the right
panel inspector. The panel shows the full composition element tree with
collapsible hierarchy — clicking a layer selects it without navigating
away from the tree view.

Closes #783

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Each layer row now shows a small color/content preview thumbnail:
- Text elements show a snippet of their content in the actual font color
- Container elements show their background color as a colored swatch
- Image elements show a tiny thumbnail of the image
- Media elements show an icon indicator

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace tiny preview thumbnails with hover highlighting — hovering a
layer row highlights the element in the preview canvas. Clicking a layer
auto-seeks the playhead to that element's start time in the timeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…erflow

- Reorder inspector tabs to Design → Layers → Renders
- Fix autoseek: walk DOM ancestors when selected element has no direct
  timeline match, so clicking a child like S2 Heading correctly seeks
  to the start of its parent scene
- Fix Renders toolbar overflow: add flex-wrap to the export controls
  header so selects and the Export button wrap instead of clipping
…k signal

setCurrentTime() only updated the store — adapter.seek() and liveTime.notify()
were never called so the iframe never moved. Add requestedSeekTime to the player
store; useTimelinePlayer subscribes and calls the real seek() path when it fires.
Section component now supports collapse/expand with a chevron toggle.
Text, Layout, and Fill sections stay expanded by default. Less-used
sections (Flex, Radius, Stroke, Effects, Clip, Transparency) start
collapsed to reduce scrolling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… render loop

resetErrors was a new function object on every render. handlePreviewIframeRef
had it as a dep, so it also changed every render. NLELayout's useEffect watching
onIframeRef would re-fire, calling setPreviewIframe again, which re-ran
useConsoleErrorCapture with the new iframe — infinite loop.
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE at 093f7d1b. Issue #783 is addressed correctly via the chosen path (Option A — dedicated Layers tab) and the bonus QoL (collapsible style sections + flex-wrap on render queue) is reasonable scope.

Issue coverage verified (Rule 2)

The user's ask in #783: "When clicking on Item B, it should show that Item B is selected and still keeping the full tree visible." The fix lands at two sites that have to agree:

  1. LayersPanel.tsx reads the full element tree from previewIframeRef.current.contentDocument (not the selection's subtree), so the tree stays whole regardless of selection.
  2. useDomSelection.ts:165-167 is the load-bearing conditional:
    if (options?.revealPanel !== false) {
      setRightCollapsed(false);
      if (rightPanelTab !== "layers") {
        setRightPanelTab("design");
      }
    }
    When the user clicks a layer from the Layers tab, rightPanelTab === "layers" is already true → the auto-switch-to-design is suppressed → the full tree stays visible. ✓

Both halves of the fix have to be present for the user-visible behavior to actually work — neither alone solves it. They are.

Three-grep discipline (per the refactor-review memory) passes

  • addEventListener cleanup: iframe.addEventListener("load", handleLoad) has a matching removeEventListener in the useEffect cleanup. ✓
  • setTimeout cleanup:
    • hoverSeekTimerRef: cleared on next hover, on hover-out, and on unmount (the useEffect(() => () => clearTimeout(ref.current), []) at line ~92). ✓
    • setTimeout(collectLayers, 100): returned via return () => clearTimeout(timer). ✓
  • React.memo on new component boundary: LayersPanel is wrapped in memo(...). ✓

Cross-loop seek pattern is sound

The requestedSeekTime / requestSeek / clearSeekRequest triple in playerStore.ts + the subscription in useTimelinePlayer.ts:330-338 is the right shape — a transient command channel for "external" callers (Layers panel) that need to drive a seek without owning the player loop. Verified the subscription's re-entry guard (state.requestedSeekTime !== null && state.requestedSeekTime !== prev.requestedSeekTime) handles the clearSeekRequest round-trip without re-firing on null. ✓

Worth a JSDoc tightening note: the requestSeek docstring says "useTimelinePlayer subscribes and calls adapter.seek() + liveTime.notify()" — would be clearer to also call out that the command is auto-cleared after consumption, so external callers don't need to (and shouldn't) call clearSeekRequest themselves.

Non-blocking observations

  1. Section's collapsible-everywhere change goes beyond "QoL improvements for the design panel." The Section component (propertyPanelPrimitives.tsx:334-376) is now collapsible for every caller, not just the ones that opt into defaultCollapsed. That's a behavior change for any consumer of <Section> elsewhere — previously the title was a <div>, now it's a <button>. Worth confirming there's no consumer that:

    • Passes an interactive element as accessory (now nested inside the toggle <button> → nested-interactive HTML violation, React warning at runtime).
    • Relies on the old <div> semantics for layout / event delegation.
    • A quick grep -rn '<Section' packages/studio/src/ should surface other call sites — if none of them pass a button/link/input as accessory, this is safe.
  2. hoverSeekTimerRef declared after the useEffect that references it (LayersPanel.tsx:89-94 vs the ref decl at :96). Works because the effect callback only reads the ref at effect-run time (post-render), by which point the const binding is initialized — but it's a stylistic landmine. A future reader will scan it as "useEffect references a binding before its declaration → bug." Swap the order so the ref decl comes first.

  3. Section's toggle button has no aria-expanded. The <button> toggles visibility but screen readers can't tell the open/closed state from the button alone. One-line a11y fix:

    <button type="button" aria-expanded={!collapsed} ...>

    Same for the layer collapse buttons in LayersPanel.tsx if you want the tree to be screen-reader navigable.

  4. useConsoleErrorCapture getting useCallback-wrapped is unrelated to the layers panel. Worth a separate-commit framing in retrospect, but acceptable as a drive-by since it stabilizes the function reference (previously resetErrors was a new function on every render, invalidating any consumer that used it as a dependency).

  5. useTimelinePlayer.ts added to .filesize-allowlist. The file just crossed the LOC threshold from the +12 lines this PR adds. Not a problem here, but the file is now perpetually on the watchlist — next time someone adds to it, consider extracting the player-loop helpers into a sibling file.

  6. hover-seek debounce at 300ms is well-tuned for "brushing past items doesn't thrash the player." Confirmed the cleanup paths are right; just a praise note.

CI

Mostly green at review time: Format ✓, Lint ✓, Test ✓, Typecheck ✓, Build ✓, Test: runtime contract ✓, Smoke: global install ✓, CodeQL ✓, File size check ✓, preview-regression ✓. CLI smoke (required), Tests on windows-latest, Render on windows-latest still in_progress — no failures yet. mergeable_state: "blocked" is the in_progress required check, not a substantive failure.

Verdict

APPROVE. Issue is addressed correctly, the architecture is sound, the three-grep discipline passes, and the nits above are all non-blocking. Ship 🚀 — fix the aria-expanded and the ref-declaration-order in a follow-up if you want them tidy.

— Rames Jusso (pr-review)

@miguel-heygen miguel-heygen merged commit 6fe9706 into main May 13, 2026
40 of 43 checks passed
@miguel-heygen miguel-heygen deleted the feat/layers-panel branch May 13, 2026 06:22
Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layers panel + design-panel QoL — focused, well-scoped change. CI is green and the contract carve-out (don't force-switch to Design when Layers is active) is implemented cleanly. Approving with a list of follow-ups; none are correctness blockers but a few are worth addressing before this gets exercised on real compositions.

No prior reviews on the PR yet, so everything below is fresh.

Strengths

  • Clean state seam between LayersPanel and the player loop. Rather than reaching into useTimelinePlayer, the panel pushes a requestedSeekTime into the store (packages/studio/src/player/store/playerStore.ts:96-98) and useTimelinePlayer subscribes (packages/studio/src/player/hooks/useTimelinePlayer.ts:330-337). Mirrors the existing pattern at NLELayout.tsx:217. Good separation.
  • Contract-aware carve-out in applyDomSelection. The rightPanelTab !== "layers" guard at packages/studio/src/hooks/useDomSelection.ts:168-170 is exactly the right shape — keep the existing canvas-click→Design behavior intact, only suppress the auto-switch when the user has deliberately parked on Layers. Dep array correctly updated (useDomSelection.ts:187).
  • Resource cleanup is correct end-to-end. Ran the JS/TS three-grep on LayersPanel.tsx: addEventListener("load") at line 95 has a matching removeEventListener at line 96; setTimeout at line 101 paired with clearTimeout at line 102; the debounced hover-seek setTimeout at line 180 has a dedicated unmount-cleanup effect at lines 106-111. No listener/timer leaks.

Important

  • .filesize-allowlist shouldn't paper over a 1-line overflow. useTimelinePlayer.ts is now exactly 501 lines (verified wc -l); the file-size cap (.github/workflows/ci.yml:321-339) is 500, so the PR adds the file to the allowlist (.filesize-allowlist:1). The cap exists to force decomposition — going to the allowlist for +1 line defeats the rule and creates a precedent. The new useEffect at useTimelinePlayer.ts:330-337 is self-contained (12 lines incl. comment) and a natural extraction. Pull it into a tiny useExternalSeekHandler(seek) hook in packages/studio/src/player/hooks/ and drop the allowlist entry. Same behavior, file stays under the cap.
  • Layer rows aren't memoized — every canvas click re-renders the entire visible list. LayersPanel itself is memo'd (LayersPanel.tsx:53), but the inline visibleLayers.map(...) body at lines 215-277 renders 50-300 row JSX elements directly. LayersPanel consumes domEditSelection from DomEditContext, so any selection change in the canvas re-runs the map. Extract the row into a memo'd LayersPanelRow keyed on (layer.key, selected, isCollapsed) so unchanged rows skip reconciliation. Failure mode: noticeable scroll/click jank on the 100-layer compositions the feature is most useful for.
  • No tests on the new component. TimelineLayerPanel.test.ts is the sibling precedent; this PR ships 302 LoC of new logic (collectLayers, getVisibleLayers, ancestor-walk seek matching) with zero coverage. Minimum I'd want: a getVisibleLayers unit test (the collapse-fold logic at LayersPanel.tsx:281-302 has a real off-by-one risk on nested collapse), and a render test that asserts clicking a row triggers applyDomSelection + requestSeek.
  • No virtualization or scroll-into-view. Two related gaps on the same primitive: (a) the list naively renders all elements (no react-window / @tanstack/react-virtual — verified neither is in packages/studio/package.json), so a 300-element composition pays a full DOM cost per render; (b) when the canvas selection lands on an offscreen layer, the row gets the highlight class but the panel doesn't scroll it into view. Selection-sync is supposed to be bidirectional — right now it's visual-only for the visible window. Add a useEffect keyed on selectedKey that scrolls the matching row into view with block: "nearest".

Nits

  • hoverSeekTimerRef referenced before declared. Used at LayersPanel.tsx:107 inside a useEffect, declared at line 123. Works at runtime because the effect closure resolves the binding after the function body finishes initializing, and Typecheck (correctly) doesn't flag closure-deferred reads of TDZ bindings. But it's confusing for the next reader. Move the useRef up to sit with the other refs near line 60.
  • Section's defaultCollapsed is only read on mount. propertyPanelPrimitives.tsx:351 uses useState(defaultCollapsed) — so if a parent ever passes a dynamic value, the section won't react. All current callsites pass a literal, so this is purely a future-proofing nit. A comment on the prop ("initial only — not reactive") would prevent future confusion. Also worth noting: collapse state resets on every tab switch since it's component-local; not blocking, but if you want stickiness, lift it into usePanelLayout later.
  • usePlayerStore.subscribe((state, prev) => ...) fires on every store update. useTimelinePlayer.ts:332 — the requestedSeekTime !== prev.requestedSeekTime check makes it cheap, but the listener still runs on every setIsPlaying, setCurrentTime, etc. zustand 5's subscribeWithSelector (or the two-arg overload subscribe(selector, listener)) only fires when the selected slice changes. Cleaner and cheaper.
  • A11y — layers panel reads as a list of unrelated buttons to screen readers. Each row is role="button" (LayersPanel.tsx:222). For a tree, the right semantics are role="tree" on the container and role="treeitem" + aria-level={layer.depth+1} + aria-expanded={!isCollapsed} + aria-selected={selected} on each row. Not gating, but the Layers feature is the kind of thing a11y issues get filed on.
  • Tag-badge map (LayersPanel.tsx:14-37) duplicates an HTML-tag taxonomy. Fine to land as-is, but consider whether domEditingLayers.ts should own the tagName -> displayLabel mapping so future panels (TimelineLayerPanel, etc.) stay consistent.

Notes

  • Issue #783 ask: "selecting Item B should highlight Item B inside the full tree, not collapse the tree to just Item B." This PR delivers that (LayersPanel.tsx always renders from the composition root, selection is purely a highlight class). Closes the issue cleanly.
  • The other 5 design-panel QoL changes (propertyPanelStyleSections.tsx collapse defaults, RenderQueue.tsx flex-wrap, hover-selection plumbing) are unrelated to #783 but small enough that bundling is fine. Might be worth a sentence in the PR description noting they're separate.

Verdict: APPROVE
Reasoning: No correctness or security blockers, CI green, resource cleanup audit passes. The allowlist entry and the missing row-memoization are quality-of-implementation issues that should be addressed in a follow-up, not gates on this PR. Issue #783 is closed correctly.

— Vai

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

inspector: Improve layer hierarchy navigation

3 participants