Skip to content

fix(studio): Layers rows show a kind icon, and only a group row has a caret - #5067

Merged
miguel-heygen merged 7 commits into
mainfrom
dlayerspanel/layers-kind-icons
Oct 5, 2026
Merged

miguel-heygen merged 7 commits into
mainfrom
dlayerspanel/layers-kind-icons

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

What a user can do now

In Studio's Layers panel, each row says what the element is with an icon people know: image, video, audio, vector, group, text or shape. Before, it showed the tag's first letters ("DI" for a div, "P", "H1"). Rows line up at the left unless they are nested: only a row with children has the expand caret (leaf rows no longer reserve its space), and each child's icon starts where its parent's label starts, one step per nesting level. Rows are inset on both sides with rounded corners, so the selected row's highlight is centred instead of running into the panel's left edge and stopping at the scrollbar.

What changed

  • New components/editor/layerKind.ts: layerKindOf(element). img/picture is image; video, audio and svg are video, audio and vector. A data-hf-group or a composition host is a group. Text comes next, then a row with layer children (the panel's existing child count) is a group, so a plain wrapper with a caret reads as a group. Text keeps its icon even when its styled spans give it a caret, and media rows keep theirs. An element with text whose descendants are all rich-text formatting tags is text: it calls the inline text editor's own hasOnlyFormattingChildren (now exported), so the Text icon means Studio edits that element in place. Anything else is a shape.
  • isCompositionHost moves from LayersPanel.tsx into layerKind.ts, so the badge's composition tint and the group icon read the same check (data-composition-src or data-composition-file).
  • LayersPanel.tsx: the badge renders that kind's icon (with the kind as its tooltip and a data-layer-kind attribute) in the same 20px tile and the same selected and composition tints. The icon's tooltip carries the row's own hint ("Group · Double-click to enter group"), so hovering the icon no longer hides it. The tag-letter table is gone. A leaf row no longer reserves a caret column. One nesting level is a caret, the icon and their gaps (48px), so a child's icon starts where its parent's label starts. Rows, and the drill-in back row, are mx-1 rounded-md.
  • SystemIcons.tsx: adds ImageIcon, Vector and Folder (Phosphor Image, BezierCurve, FolderSimple), beside the existing Film, Music, Type and Square.

Tests

  • layerKind.test.ts (new): 17 cases plus a children case covering every kind, <picture>, a wrapper with layer children (group), text with a classed span and children (still text), both composition-host attributes, rich text, text beside an image, a <sub> (not editable in place, so a shape), whitespace-only and media-in-a-div. It failed first because the module did not exist. Dropping the host check from the group rule fails both host cases; replacing the text check with true fails the image and <sub> cases.
  • layersPanelExport.test.tsx: rows read their names, not tag letters ("Title", not "DiTitle"). A new case over a group nested in a group checks the kinds (text, group, image, group, image), that only a group's row starts with the caret, that a top-level leaf starts with its icon, the indents 8, 8, 56, 56 and 104px, that a plain wrapper with children is a group with a caret, that four kinds render four different icons, and that each icon's tooltip keeps its row's hint. It failed first on main ("DiTitle"). Restoring the leaf placeholder fails it, and so does the old 22px step.
  • layerKind, layersPanelExport, LayersPanel and domEditInlineText tests together: 46 passed, 3 runs in a row. Each of these mutants fails at least one test: ignoring children in the group rule, checking children before text, rendering every row's icon as a shape, dropping picture, and a tooltip without the row's hint. tsc --noEmit in packages/studio shows no errors besides core's generated runtime file, which a fresh tree does not have. The comment-share ratchet passes against the merge base, and oxfmt and oxlint are clean on the changed files.

What I did not exercise

  • The audio row: the panel lists only elements with a box on the canvas, so the fixture's <audio> has no row. The unit test covers its icon mapping.
  • Drag-reorder in a real browser. The export test covers it in happy-dom.
  • macOS pixels. The captures are headless Chromium on Linux, where the system font falls back to DejaVu Sans and labels draw 1 px above the icons' centre. The same row markup in Chromium on macOS (San Francisco) is centred to the pixel, and the row's boxes are centred in both.

Before

Studio's Layers panel on main (6bb85af), Vite dev server, a fixture with text, rich text, an image, a video, an svg, a long name, a dozen boxes and a two-child group. Shots are at 2x with the list scrolled to the end and Back selected: the window 1440 wide (left) and 1100 wide (right), light then dark.

Before: Layers panel, light, 1440 and 1100 wide
Before: Layers panel, dark, 1440 and 1100 wide

2x zooms, light then dark: the selected row's highlight runs to the left edge, and the group's children start past the group's badge.

Before: zooms of the selected row and the group

Inside the group (double-click its row), light then dark: the back row runs edge to edge.

Before: inside the group

After

This branch, same fixture and views (captured at a70f121; every head since a0ea470 was pixel-identical in all ten views).

After: Layers panel, light, 1440 and 1100 wide
After: Layers panel, dark, 1440 and 1100 wide

2x zooms: the selected row (light, dark) is inset on both sides with rounded corners. In the group (1440 light and dark, then 1100 light and dark), each child's icon starts where the group's label starts.

After: zooms of the selected row and the group

Inside the group, light then dark: the back row is inset like the rows under it.

After: inside the group

… a caret

Rows show what an element is (image, video, audio, vector, group, text or shape) instead of the tag's first letters. The caret column sits only on rows with children, a group's children indent under its icon, and rows are inset with rounded corners so the selection reads centred.
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 2040 (base branch 2040), smooth 1631 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

@miguel-heygen
miguel-heygen marked this pull request as ready for review October 5, 2026 14:55

@somanshreddy somanshreddy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review at a0ea4709 (full PR). No blockers.

What holds.

  • layerKindOf reuses the inline editor's own hasOnlyFormattingChildren, so the Text icon and in-place editing can't drift apart. The hasOnlyFormattingChildren export is the only behaviour-neutral change in domEditInlineText.ts.
  • isCompositionHost moving into layerKind.ts keeps the tint and the group icon on one check.
  • The 48 px step (CARET + GAP + ICON + GAP) puts a child's icon where its parent's label starts. It matches the 8/8/56/56/104 test.
  • The new icons exist in SystemIcons.tsx. Film, Music, Square and Type were already there.

Should consider:

  1. A caret on a "shape" row. The caret is drawn on hasChildren (LayersPanel.tsx:458), and childCount is getDirectLayerChildren(el).length (domEditingElement.ts:385): any layer child of any element counts. So a plain wrapper <div> holding boxes, with no data-hf-group, gets the expand caret and the Square/"Shape" icon. "Only a group's row has the expand caret" holds for data-hf-group and composition hosts, not for plain containers. Either return group from layerKindOf when the element has layer children, or narrow the claim. The tests' nested case uses data-hf-group, so the plain-wrapper row isn't pinned either way.

Lows:
2. The icon's title (LayersPanel.tsx:516) shadows the row's title while hovering the icon. A draggable group's "Double-click to enter group" and a fixed layer's "This layer can't be reordered" don't show there.
3. <canvas> (and <iframe>) now read as "Shape". Before it was "Cn". If a canvas-backed layer (Lottie, three) is common in compositions, it may deserve its own kind or the image icon.

Mutations. I ran 8 mutants on layerKind.test.ts + layersPanelExport.test.tsx + the domEditInlineText tests; 6 were caught. The tree was restored and verified clean after each.

  • Caught:
    • svg and audio mappings dropped;
    • .trim() removed;
    • data-hf-group check dropped;
    • old 22 px step;
    • leaf spacer restored.
  • Survived:
    • picture dropped from the image rule. No case covers it.
    • Every row rendering KIND_ICON.shape. Only data-layer-kind is asserted, not the rendered icon, so a broken KIND_ICON map passes.

CI at a0ea4709: 68 pass, 20 pending, 6 skipping, 0 failing at review time.

What I ran: a read of the full runtime diff, plus the childCount source; vitest run on the 4 test files (37 passed); the 8 mutants. I didn't view the Before/After sheets.

This is a comment, not an approval.

— Somu

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving at e9090e1.

The earlier review points are resolved:

  • A row's icon and caret now come from the same hasChildren, so a plain wrapper with child layers shows as Group with a caret.
  • The icon's tooltip now includes the row's hint.
  • <picture> is covered.
  • The render test checks that four kinds draw four distinct icons.

Reuse: the classifier reuses the inline editor's text rule (hasOnlyFormattingChildren) and moves isCompositionHost rather than copying it. The icons come from SystemIcons, with the same Type/Film/Square set the property panel header already uses for text, media and other. I found no existing row-kind helper this should have called. The old two-letter tag table is gone, so the change removes code overall.

Tests: layerKind, layersPanelExport, LayersPanel and domEditInlineText pass 46/46 three times, and tsc --noEmit is clean. I ran 8 mutations and 7 were caught: dropping hasChildren from the classifier or the panel, dropping the whitespace-only text check, dropping the composition-host check, dropping the hint from the icon's tooltip, putting the leaf placeholder back, and changing the indent.

Non-blocking:

  1. Swapping the audio icon for the shape icon passes, because no render test has an <audio> row. One audio row in the render test would pin it.
  2. A paragraph the inline editor won't edit, such as <p>H<sub>2</sub>O</p>, reads as Shape. The test pins this on purpose and it follows the editor's rule, but someone will see a Square on what looks like text.

CI: 32 pass and 28 still running at this head, with no failures so far.

— Rames

@jrusso1020 jrusso1020 left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving at a70f121b. The only change since my approval at e9090e18 is layerKindOf, which now checks text before the has-children group rule.

  • Order: data-hf-group and composition hosts are still groups first. Then text with only formatting children. Then a row with children becomes a group, else a shape. So <p>Hi <span class="x">there</span></p> keeps the Text icon, and a plain <div> wrapper is still a Group.
  • Tests: the Layers tests pass three runs in a row (35/35), and tsc --noEmit is clean in studio.
  • Mutations: restoring the old order (hasChildren || back in the group check) fails the new assertion. Dropping the final hasChildren ? "group" fails the wrapper assertion. Both are pinned.

The note from my last review stands, and nothing here changes it: <p>H<sub>2</sub>O</p> still is not Text, because sub is not a formatting tag. It shows as Shape, or as Group when the sub is its own layer row. This is non-blocking.

— Rames

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit f7d6734 Oct 5, 2026
181 of 182 checks passed
@miguel-heygen
miguel-heygen deleted the dlayerspanel/layers-kind-icons branch October 5, 2026 17:03
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.

3 participants