fix(studio,core): groups open by default, headers fit, and two contracts stop being promises

The four items left after the browser pass, plus the two architectural
findings from the review that were held for a decision.

Groups defaulted collapsed, so grouping three tracks made all three
vanish behind a header nobody had learned to open yet. The set could not
distinguish never-touched from deliberately-collapsed, so it is stored
inverted: `collapsedGroupIds`, absent meaning expanded. Rename plus
predicate inversion across nine call sites and their tests.

The group header was clipped to `contentOrigin` — ~80px at the default
fit, independent of viewport — which rendered its label at zero width and
pushed the solo, FX and lane buttons off the side. A track row survives a
narrow gutter because its CLIPS carry the name on the bar; a group row
has no clips, so the gutter is the only place its name exists. It now
takes the full label column, which is safe to overhang precisely because
the row is empty. Measured 80 -> 232, label 0 -> 45px.

Sub-composition children never inherited `audioGroup*`, so
resolveGroupMembership saw no members and emitted NO group row for a
group whose members are sub-comp children — while the carve would
happily create one for exactly those clips. Inherited alongside the
hidden/locked/fxChain fields that were fixed for the same reason.

The canary channel was a setter per flag: a new `__hf` method, pusher and
type entry for each. Replaced with one `__hf.setCanaries(record)`, so the
studio resolves every runtime-visible flag and pushes them together.
Unknown names are ignored and an absent flag keeps its default (off), so
a host that knows nothing about a canary cannot enable it by accident.

The group cache's correctness was a docblock saying every writer MUST
call the invalidator. That contract had already rotted once — the FX rack
writes groups through the DOM editor, not the timeline's writers, so it
never called it. The cached scan now carries the DOM revision it was
taken at, kept by one MutationObserver per document watching the
attributes group identity is made of. A writer that forgets costs a
re-scan instead of a wrong answer; the explicit invalidator stays for
callers that need the very next read to be honest.

Verified in the browser: group expanded on load with no seeding, header
232px with the label and all four controls visible, `setCanaries` present
on the runtime and the per-flag setter gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:40 -07:00
co-authored by Claude Opus 5
parent ff4965037c
commit 25d7af8a5c
23 changed files with 224 additions and 82 deletions
@@ -335,7 +335,6 @@ describe("Timeline provider boundary", () => {
audioGroup: "voiceover",
},
],
expandedGroupIds: new Set(["voiceover"]),
});
const root = createRoot(host);
act(() => root.render(React.createElement(Timeline)));
@@ -25,10 +25,9 @@ interface TimelineGroupRowProps {
top: number;
height: number;
virtualized: boolean;
contentOrigin: number;
theme: TimelineTheme;
rovingTargetId?: string | null;
expandedGroupIds: ReadonlySet<string>;
collapsedGroupIds: ReadonlySet<string>;
expandedLaneOwnerIds: ReadonlySet<string>;
toggleGroupExpanded: (id: string) => void;
toggleLaneOwnerExpanded: (id: string) => void;
@@ -43,10 +42,9 @@ export function TimelineGroupRow({
top,
height,
virtualized,
contentOrigin,
theme,
rovingTargetId = null,
expandedGroupIds,
collapsedGroupIds,
expandedLaneOwnerIds,
toggleGroupExpanded,
toggleLaneOwnerExpanded,
@@ -106,7 +104,7 @@ export function TimelineGroupRow({
<TimelineGroupHeader
label={group.label}
memberCount={group.memberTracks.length}
isExpanded={expandedGroupIds.has(group.id)}
isExpanded={!collapsedGroupIds.has(group.id)}
onToggleExpanded={() => toggleGroupExpanded(group.id)}
laneCount={groupAutomationLanes(memberElements).length}
isLaneOpen={isLaneOpen}
@@ -127,7 +125,14 @@ export function TimelineGroupRow({
onFxChainChange={(next) => writeGroupFxChain(next, false)}
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
onOpenFxRack={openGroupFxRack}
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
// Always the full label column, never squeezed down to `contentOrigin`.
// A track row can afford a narrow gutter because its CLIPS carry the
// name on the bar; a group row has no clips at all, so the gutter is
// the only place its name exists — and at the default fit the gutter is
// ~80px, which rendered the label at zero width and clipped the solo,
// FX and lane buttons off the side. Overhanging into the lane area is
// safe precisely because this row is empty (see `propertyRows={[]}`).
columnWidth={LABEL_COL_W}
theme={theme}
/>
{isLaneOpen && (
@@ -118,7 +118,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
selectedElementId: null,
selectedElementIds: next.selectedElementIds ?? new Set(),
expandedClipIds: new Set(next.expandedClipIds ?? []),
expandedGroupIds: new Set(),
collapsedGroupIds: new Set(),
expandedLaneOwnerIds: new Set(),
groups: [],
trackGroupOf: new Map(),
@@ -100,7 +100,7 @@ export function TimelineLanes({
// from resolving into a second timeline that renders the same logical rows.
const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
const { expandedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
useTimelineGroupDisclosure();
const automationLanes = useAutomationLanes();
useAutomationSelectionKeyboard({ lanes: automationLanes });
@@ -166,10 +166,9 @@ export function TimelineLanes({
top={rowGeometry.getRowTop(row)}
height={rowGeometry.getRowHeight(row)}
virtualized={rowsVirtualized}
contentOrigin={contentOrigin}
theme={theme}
rovingTargetId={keyboard.rovingTargetId}
expandedGroupIds={expandedGroupIds}
collapsedGroupIds={collapsedGroupIds}
expandedLaneOwnerIds={expandedLaneOwnerIds}
toggleGroupExpanded={toggleGroupExpanded}
toggleLaneOwnerExpanded={toggleLaneOwnerExpanded}
@@ -58,7 +58,7 @@ function model(overrides: Partial<Parameters<typeof buildTimelineLogicalRows>[0]
selectedElementId: "active",
selectedElementIds: new Set(),
expandedClipIds: new Set(["active"]),
expandedGroupIds: new Set(),
collapsedGroupIds: new Set(),
expandedLaneOwnerIds: new Set(),
groups: [],
trackGroupOf: new Map(),
@@ -248,7 +248,7 @@ describe("resolveTimelineNavigationTarget", () => {
selectedElementId: null,
selectedElementIds: new Set(),
expandedClipIds: new Set(),
expandedGroupIds: new Set(),
collapsedGroupIds: new Set(),
expandedLaneOwnerIds: new Set(),
groups: [],
trackGroupOf: new Map(),
@@ -77,8 +77,8 @@ export interface BuildTimelineLogicalRowsInput {
selectedElementId: string | null;
selectedElementIds: ReadonlySet<string>;
expandedClipIds: ReadonlySet<string>;
/** Groups whose member rows the caret has shown (structural, not lanes). */
expandedGroupIds: ReadonlySet<string>;
/** Groups the caret has COLLAPSED — absent means expanded, the default. */
collapsedGroupIds: ReadonlySet<string>;
/** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
expandedLaneOwnerIds: ReadonlySet<string>;
groups: readonly TimelineTrackGroupInfo[];
@@ -251,7 +251,7 @@ export function buildTimelineLogicalRows({
selectedElementId,
selectedElementIds,
expandedClipIds,
expandedGroupIds,
collapsedGroupIds,
expandedLaneOwnerIds,
groups,
trackGroupOf,
@@ -300,7 +300,7 @@ export function buildTimelineLogicalRows({
// count.
function emitGroup(group: TimelineTrackGroupInfo): void {
const groupRowId = timelineGroupRowId(group.id);
const groupExpanded = expandedGroupIds.has(group.id);
const groupExpanded = !collapsedGroupIds.has(group.id);
rows.push({
id: groupRowId,
kind: "row",
@@ -6,7 +6,7 @@ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
/** The four pieces of group-disclosure state a group row's header reads and writes. */
export function useTimelineGroupDisclosure() {
return {
expandedGroupIds: usePlayerStore((s) => s.expandedGroupIds),
collapsedGroupIds: usePlayerStore((s) => s.collapsedGroupIds),
expandedLaneOwnerIds: usePlayerStore((s) => s.expandedLaneOwnerIds),
toggleGroupExpanded: usePlayerStore((s) => s.toggleGroupExpanded),
toggleLaneOwnerExpanded: usePlayerStore((s) => s.toggleLaneOwnerExpanded),
@@ -35,7 +35,7 @@ interface TimelineLogicalFocusInput {
export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
const expandedClipIds = usePlayerStore((state) => state.expandedClipIds);
const expandedGroupIds = usePlayerStore((state) => state.expandedGroupIds);
const collapsedGroupIds = usePlayerStore((state) => state.collapsedGroupIds);
const expandedLaneOwnerIds = usePlayerStore((state) => state.expandedLaneOwnerIds);
const projectId = usePlayerStore((state) => state.timelineProjectId);
const logicalRows = useTimelineLogicalRows({
@@ -45,7 +45,7 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
selectedElementId: input.selectedElementId,
selectedElementIds: input.selectedElementIds,
expandedClipIds,
expandedGroupIds,
collapsedGroupIds,
expandedLaneOwnerIds,
groups: input.groups,
trackGroupOf: input.trackGroupOf,
@@ -22,7 +22,7 @@ const displayTrackOrder = tracks.map(([track]) => track);
const laneCounts = new Map<string, number>();
const selectedElementIds = new Set<string>();
const expandedClipIds = new Set<string>();
const expandedGroupIds = new Set<string>();
const collapsedGroupIds = new Set<string>();
const expandedLaneOwnerIds = new Set<string>();
const groups: never[] = [];
const trackGroupOf = new Map();
@@ -37,7 +37,7 @@ function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]
selectedElementId: null,
selectedElementIds,
expandedClipIds,
expandedGroupIds,
collapsedGroupIds,
expandedLaneOwnerIds,
groups,
trackGroupOf,
@@ -14,7 +14,7 @@ export function useTimelineLogicalRows({
selectedElementId,
selectedElementIds,
expandedClipIds,
expandedGroupIds,
collapsedGroupIds,
expandedLaneOwnerIds,
groups,
trackGroupOf,
@@ -29,7 +29,7 @@ export function useTimelineLogicalRows({
selectedElementId,
selectedElementIds,
expandedClipIds,
expandedGroupIds,
collapsedGroupIds,
expandedLaneOwnerIds,
groups,
trackGroupOf,
@@ -38,7 +38,7 @@ export function useTimelineLogicalRows({
[
displayTrackOrder,
expandedClipIds,
expandedGroupIds,
collapsedGroupIds,
expandedLaneOwnerIds,
groups,
trackGroupOf,
@@ -131,7 +131,7 @@ function emitGroupRows(
*/
function groupTimelineTracks(
rawTracks: [number, TimelineElement[]][],
expandedGroupIds: ReadonlySet<string>,
collapsedGroupIds: ReadonlySet<string>,
): {
tracks: [number, TimelineElement[]][];
groups: TimelineTrackGroupInfo[];
@@ -154,7 +154,7 @@ function groupTimelineTracks(
emitted.add(groupId);
const info = buildGroupInfo(groupId, trackNum, membership, rawByTrack);
groups.push(info);
emitGroupRows(info, rawByTrack, trackGroupOf, tracks, expandedGroupIds.has(groupId));
emitGroupRows(info, rawByTrack, trackGroupOf, tracks, !collapsedGroupIds.has(groupId));
}
return { tracks, groups, trackGroupOf };
}
@@ -183,7 +183,7 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
return Array.from(map.entries()).sort(([a], [b]) => a - b);
}, [expandedElements]);
const expandedGroupIds = usePlayerStore((s) => s.expandedGroupIds);
const collapsedGroupIds = usePlayerStore((s) => s.collapsedGroupIds);
const { tracks, groups, trackGroupOf } = useMemo(() => {
if (!isCanaryEnabled("audio-groups")) {
return {
@@ -192,8 +192,8 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
trackGroupOf: new Map<number, TimelineTrackGroupInfo>(),
};
}
return groupTimelineTracks(rawTracks, expandedGroupIds);
}, [rawTracks, expandedGroupIds]);
return groupTimelineTracks(rawTracks, collapsedGroupIds);
}, [rawTracks, collapsedGroupIds]);
const trackStyles = useMemo(() => {
const map = new Map<number, TrackVisualStyle>();
@@ -55,11 +55,13 @@ describe("collapsed audio groups", () => {
audioGroup: "voiceover",
});
function renderGrouped(): {
/** `collapsed` seeds the collapsed set — expanded is the default state. */
function renderGrouped(collapsed = false): {
layout: ReturnType<typeof useTimelineTrackLayout>;
unmount: () => void;
} {
enabledCanaries.add("audio-groups");
if (collapsed) usePlayerStore.setState({ collapsedGroupIds: new Set(["voiceover"]) });
const elements = [member("voice-1", 0), member("voice-2", 1)];
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
function Probe() {
@@ -77,7 +79,7 @@ describe("collapsed audio groups", () => {
// still reserve height, turning that null into visible dead space — the row
// list and the logical rows have to agree.
it("emits only the anchor row while the group is collapsed", () => {
const { layout, unmount } = renderGrouped();
const { layout, unmount } = renderGrouped(true);
expect(layout.groups).toHaveLength(1);
expect(layout.groups[0]!.memberTracks).toEqual([0, 1]);
// The anchor (0 - 0.5) and nothing else.
@@ -94,14 +96,25 @@ describe("collapsed audio groups", () => {
// display list — which a collapsed group does not appear in. Collapsed is the
// default, so that was every group until someone opened it.
it("carries its member elements even while collapsed", () => {
const { layout, unmount } = renderGrouped();
const { layout, unmount } = renderGrouped(true);
expect(layout.trackOrder).toEqual([-0.5]); // collapsed: no member rows
expect(layout.groups[0]!.memberElements.map((el) => el.id)).toEqual(["voice-1", "voice-2"]);
unmount();
});
// The reason the set is stored inverted. As an expanded-set, "absent" could
// not tell never-touched from deliberately-collapsed, so a freshly created
// group started collapsed — grouping three tracks made all three vanish
// behind a header the user had not yet learned to open.
it("is expanded by default, with nothing seeded", () => {
const { layout, unmount } = renderGrouped();
expect(usePlayerStore.getState().collapsedGroupIds.size).toBe(0);
expect(layout.trackOrder).toEqual([-0.5, 0, 1]);
unmount();
});
it("emits the member rows once the group is expanded", () => {
usePlayerStore.setState({ expandedGroupIds: new Set(["voiceover"]) });
// Expanded is the default now — nothing to seed.
const { layout, unmount } = renderGrouped();
expect(layout.trackOrder).toEqual([-0.5, 0, 1]);
for (const track of layout.groups[0]!.memberTracks) {