From 8aa91fc27b60f42db5892dca0c1568ba6d03b469 Mon Sep 17 00:00:00 2001
From: th
Date: Fri, 24 Apr 2026 08:08:23 +0200
Subject: [PATCH] 0.13.1: new track-group lands above the active layer
addTrackGroup takes an optional anchorTrackId and inserts directly
above it in trackOrder; anchors nested inside another group resolve
to their parent's slot. The timeline's group-add button forwards
selectedLayerId so the new group lands where the user is working
instead of always jumping to the top. Covered by store + UI tests.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
package.json | 2 +-
src/components/Timeline.vue | 5 ++-
src/devtools/tests.ts | 80 +++++++++++++++++++++++++++++++++++++
src/stores/scene.ts | 29 +++++++++++++-
src/version.ts | 2 +-
5 files changed, 113 insertions(+), 5 deletions(-)
diff --git a/package.json b/package.json
index e35fe45..4d593c2 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
- "version": "0.13.0",
+ "version": "0.13.1",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue
index d007ca1..6226321 100644
--- a/src/components/Timeline.vue
+++ b/src/components/Timeline.vue
@@ -1592,7 +1592,10 @@ function toggleGroupCollapsed(groupId: string) {
}
function addGroup() {
- store.addTrackGroup()
+ // Drop the group directly above whatever the user is currently
+ // working on — layer selection is the only active-track signal
+ // today. Falls back to the top of the stack when nothing's active.
+ store.addTrackGroup(undefined, store.selectedLayerId ?? null)
}
async function renameGroupPrompt(groupId: string, current: string) {
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index aa1df5c..4be670d 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -2666,6 +2666,41 @@ export function registerAllTests(): void {
expect(s.scene.trackOrder![0]).toBe(g.id)
})
+ test('addTrackGroup with an anchor inserts directly above that track', () => {
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#111' })
+ const l1 = s.addLayer(a.id)! // trackOrder ends up [l2, l1] via unshift
+ const l2 = s.addLayer(a.id)!
+ // Anchor on the second layer → group lands at its index, shoving it down.
+ const g = s.addTrackGroup('G', l1.id)
+ const order = s.scene.trackOrder ?? []
+ const gIdx = order.indexOf(g.id)
+ const l1Idx = order.indexOf(l1.id)
+ expect(gIdx).toBe(l1Idx - 1)
+ void l2
+ })
+
+ test('addTrackGroup with an anchor inside an existing group lands above the parent group', () => {
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#111' })
+ const l = s.addLayer(a.id)!
+ const parent = s.addTrackGroup('Parent')
+ s.moveTrackIntoGroup(l.id, parent.id)
+ // Anchor is nested inside `parent` — new group must sit above `parent`,
+ // not attempt to nest itself inside another group.
+ const fresh = s.addTrackGroup('Fresh', l.id)
+ const order = s.scene.trackOrder ?? []
+ expect(order.indexOf(fresh.id)).toBe(order.indexOf(parent.id) - 1)
+ })
+
+ test('addTrackGroup with an unknown anchor falls back to the top', () => {
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#111' })
+ s.addLayer(a.id)!
+ const g = s.addTrackGroup('G', 'ghost-id')
+ expect(s.scene.trackOrder![0]).toBe(g.id)
+ })
+
test('moveTrackIntoGroup moves track from top-level to group childIds', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'G2', width: 10, height: 10, fill: '#222' })
@@ -5027,6 +5062,51 @@ export function registerAllTests(): void {
})
})
+ suite('ui — Timeline new-group button', () => {
+ test('clicking the new-group button with a selected layer inserts the group above it', async () => {
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'Anchor', width: 10, height: 10, fill: '#111' })
+ const top = s.addLayer(a.id)!
+ const anchor = s.addLayer(a.id)!
+ s.selectLayer(anchor.id)
+ const handle = mountComponent(Timeline, {})
+ await nextTick()
+ const btn = handle.host.querySelector(
+ `button[aria-label="${i18nT('timeline.addGroupTooltip')}"]`,
+ ) as HTMLButtonElement
+ expect(btn).toBeTruthy()
+ btn.click()
+ await nextTick()
+ const order = s.scene.trackOrder ?? []
+ const groupId = (s.scene.groups ?? [])[0]?.id
+ expect(groupId).toBeTruthy()
+ const gIdx = order.indexOf(groupId!)
+ const anchorIdx = order.indexOf(anchor.id)
+ // Group sits right above the anchor layer, pushing it down by one slot.
+ expect(gIdx).toBe(anchorIdx - 1)
+ // Unrelated `top` layer keeps its own slot — not dragged along.
+ expect(order.indexOf(top.id) >= 0).toBe(true)
+ handle.unmount()
+ })
+
+ test('clicking the new-group button with no selection unshifts to the top (regression)', async () => {
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#111' })
+ s.addLayer(a.id)
+ s.selectLayer(null)
+ const handle = mountComponent(Timeline, {})
+ await nextTick()
+ const btn = handle.host.querySelector(
+ `button[aria-label="${i18nT('timeline.addGroupTooltip')}"]`,
+ ) as HTMLButtonElement
+ btn.click()
+ await nextTick()
+ const groupId = (s.scene.groups ?? [])[0]?.id
+ expect(s.scene.trackOrder?.[0]).toBe(groupId)
+ handle.unmount()
+ })
+ })
+
suite('ui — Timeline audio track', () => {
test('audio track renders as an .audio-track lane with one block per clip', async () => {
const s = makeFreshStore()
diff --git a/src/stores/scene.ts b/src/stores/scene.ts
index 4325c05..2cd6fea 100644
--- a/src/stores/scene.ts
+++ b/src/stores/scene.ts
@@ -2660,14 +2660,39 @@ export const useSceneStore = defineStore('scene', {
else inst.rotation = r
}
},
- addTrackGroup(name?: string): TrackGroup {
+ /** Create an empty group. When `anchorTrackId` is given the group
+ * lands directly above that track in trackOrder — resolving to
+ * the parent group's position if the anchor sits inside one. Falls
+ * back to the top of the stack when the anchor is missing or the
+ * scene has no trackOrder yet. */
+ addTrackGroup(name?: string, anchorTrackId?: string | null): TrackGroup {
const group: TrackGroup = {
id: uid(),
name: name?.trim() || 'Group',
childIds: [],
}
;(this.scene.groups ||= []).push(group)
- ;(this.scene.trackOrder ||= []).unshift(group.id)
+ const order = (this.scene.trackOrder ||= [])
+
+ let insertAt = 0
+ if (anchorTrackId) {
+ const direct = order.indexOf(anchorTrackId)
+ if (direct >= 0) {
+ insertAt = direct
+ } else {
+ // Anchor is inside a group — put the new group above the
+ // group container instead of trying to wedge in between
+ // group children (nested groups are not supported).
+ const parent = this.scene.groups?.find((g) =>
+ g.childIds.includes(anchorTrackId),
+ )
+ if (parent) {
+ const pi = order.indexOf(parent.id)
+ if (pi >= 0) insertAt = pi
+ }
+ }
+ }
+ order.splice(insertAt, 0, group.id)
return group
},
/** Create a new group and move every currently-selected layer
diff --git a/src/version.ts b/src/version.ts
index 429044c..e80b2a7 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
-export const APP_VERSION = '0.13.0'
+export const APP_VERSION = '0.13.1'
export const APP_NAME = 'RUWIO'
|