From 86ec7aa835a96166802ba641d6bf52f3a79d77fa Mon Sep 17 00:00:00 2001
From: th
Date: Fri, 24 Apr 2026 08:00:02 +0200
Subject: [PATCH] =?UTF-8?q?0.13.0:=20unify=20all=20track=20kinds=20?=
=?UTF-8?q?=E2=80=94=20same=20height,=20same=20drag,=20same=20grouping?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Scripts bumped from 26px to 30px so the five track kinds read as one
visual list; clip / script / audio tracks lose their kind-specific
gradient backgrounds now that they're no longer fixed-position
chrome.
- Audio tracks pick up the standard drag attributes + handlers; they
now participate in the same trackOrder flow as layer / clip / script
tracks and can be dropped into groups.
- normalizeScenePage accepts scripts + audio as group children (used
to silently strip them); the per-kind ordered appending is replaced
with a single tail loop so no kind is hardcoded to the bottom.
- moveTrackIntoGroup color cascade now covers all four groupable kinds
(scripts + audio inherit group colour like layers + clip tracks).
- Group expansion in Timeline emits scripts + audio as first-class
sub-track children, with the shared in-group / sub-track classes
and group-aware drag handlers.
- Tests: move-into-group + color cascade for scripts + audio, legacy
childIds roundtrip preserves both kinds, audio track is draggable,
script track is 30px.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
package.json | 2 +-
src/components/Timeline.vue | 151 ++++++++++++++++++++++++++----------
src/devtools/tests.ts | 85 ++++++++++++++++++++
src/stores/scene.ts | 50 +++++++-----
src/version.ts | 2 +-
5 files changed, 227 insertions(+), 63 deletions(-)
diff --git a/package.json b/package.json
index a85dcdf..e35fe45 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
- "version": "0.12.0",
+ "version": "0.13.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue
index a2c6ce4..d007ca1 100644
--- a/src/components/Timeline.vue
+++ b/src/components/Timeline.vue
@@ -99,6 +99,9 @@ interface TimelineScriptTrack {
orderIndex: number
locked: boolean
color: string | null
+ inGroup: boolean
+ parentGroupId?: string
+ groupChildIndex?: number
}
interface TimelineAudioTrack {
@@ -109,6 +112,9 @@ interface TimelineAudioTrack {
locked: boolean
muted: boolean
color: string | null
+ inGroup: boolean
+ parentGroupId?: string
+ groupChildIndex?: number
}
type TimelineTrack =
@@ -774,6 +780,7 @@ const tracks = computed(() => {
orderIndex,
locked: !!st.locked,
color: st.color ?? null,
+ inGroup: false,
})
return
}
@@ -787,6 +794,7 @@ const tracks = computed(() => {
locked: !!at.locked,
muted: !!at.muted,
color: at.color ?? null,
+ inGroup: false,
})
return
}
@@ -820,6 +828,37 @@ const tracks = computed(() => {
parentGroupId: grp.id,
groupChildIndex: childIndex,
})
+ return
+ }
+ const cst = scriptTrackById.value.get(cid)
+ if (cst) {
+ out.push({
+ type: 'script',
+ scriptTrackId: cst.id,
+ name: cst.name ?? t('timeline.scriptTrackName'),
+ orderIndex: -1,
+ locked: !!cst.locked,
+ color: cst.color ?? null,
+ inGroup: true,
+ parentGroupId: grp.id,
+ groupChildIndex: childIndex,
+ })
+ return
+ }
+ const cat = audioTrackById.value.get(cid)
+ if (cat) {
+ out.push({
+ type: 'audio',
+ audioTrackId: cat.id,
+ name: cat.name ?? t('timeline.audioTrackName'),
+ orderIndex: -1,
+ locked: !!cat.locked,
+ muted: !!cat.muted,
+ color: cat.color ?? null,
+ inGroup: true,
+ parentGroupId: grp.id,
+ groupChildIndex: childIndex,
+ })
}
})
}
@@ -846,10 +885,14 @@ const tracks = computed(() => {
return true
}
if (tr.type === 'script') {
- return !tr.locked
+ if (tr.locked) return false
+ if (lockedGroupChildIds.has(tr.scriptTrackId)) return false
+ return true
}
if (tr.type === 'audio') {
- return !tr.locked
+ if (tr.locked) return false
+ if (lockedGroupChildIds.has(tr.audioTrackId)) return false
+ return true
}
// group: hide when locked
return !lockedGroupIds.has(tr.groupId)
@@ -870,19 +913,42 @@ function isTrackHead(track: TimelineTrack): boolean {
}
function trackChildIndex(track: TimelineTrack): number | null {
- if (track.type === 'layer' || track.type === 'clip') {
+ if (
+ track.type === 'layer' ||
+ track.type === 'clip' ||
+ track.type === 'script' ||
+ track.type === 'audio'
+ ) {
return track.groupChildIndex ?? null
}
return null
}
function trackParentGroupId(track: TimelineTrack): string | null {
- if (track.type === 'layer' || track.type === 'clip') {
+ if (
+ track.type === 'layer' ||
+ track.type === 'clip' ||
+ track.type === 'script' ||
+ track.type === 'audio'
+ ) {
return track.parentGroupId ?? null
}
return null
}
+function isGroupable(track: TimelineTrack): track is
+ | TimelineLayerTrack
+ | TimelineClipTrack
+ | TimelineScriptTrack
+ | TimelineAudioTrack {
+ return (
+ track.type === 'layer' ||
+ track.type === 'clip' ||
+ track.type === 'script' ||
+ track.type === 'audio'
+ )
+}
+
function isDropBefore(track: TimelineTrack): boolean {
if (dropIndex.value === null) return false
if (!isTrackHead(track)) return false
@@ -891,7 +957,7 @@ function isDropBefore(track: TimelineTrack): boolean {
if (trackParentGroupId(track) !== dropWithinGroupId.value) return false
return dropIndex.value === trackChildIndex(track)
}
- if ((track.type === 'layer' || track.type === 'clip') && track.inGroup) return false
+ if (isGroupable(track) && track.inGroup) return false
return dropIndex.value === track.orderIndex
}
@@ -906,7 +972,7 @@ function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined)
if (nextTrack && trackChildIndex(nextTrack) === ci) return false
return true
}
- if ((track.type === 'layer' || track.type === 'clip') && track.inGroup) return false
+ if (isGroupable(track) && track.inGroup) return false
if (dropIndex.value !== track.orderIndex + 1) return false
if (nextTrack && nextTrack.orderIndex === track.orderIndex) return false
return true
@@ -3432,10 +3498,22 @@ onBeforeUnmount(() => {
dragging: dragTrackId === track.scriptTrackId,
colored: !!track.color,
locked: track.locked,
+ 'in-group': track.inGroup,
+ 'sub-track': track.inGroup,
}"
:style="trackColorStyle(track.color)"
- @dragover="!track.locked ? onTrackDragOver($event, track.orderIndex) : null"
- @drop="!track.locked ? onTrackDrop($event) : null"
+ @dragover="
+ !track.locked
+ ? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
+ ? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
+ : onTrackDragOver($event, track.orderIndex))
+ : null
+ "
+ @drop="
+ !track.locked
+ ? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event))
+ : null
+ "
@dragleave="onTrackDragLeave"
>
{
v-else-if="track.type === 'audio'"
class="track audio-track"
:class="{
+ 'drop-before': isDropBefore(track),
+ 'drop-after': isDropAfter(track, tracks[idx + 1]),
+ dragging: dragTrackId === track.audioTrackId,
colored: !!track.color,
locked: track.locked,
muted: track.muted,
+ 'in-group': track.inGroup,
+ 'sub-track': track.inGroup,
}"
:style="trackColorStyle(track.color)"
:data-audio-track-id="track.audioTrackId"
+ @dragover="
+ !track.locked
+ ? (track.inGroup && track.parentGroupId !== undefined && track.groupChildIndex !== undefined
+ ? onGroupChildDragOver($event, track.parentGroupId, track.groupChildIndex)
+ : onTrackDragOver($event, track.orderIndex))
+ : null
+ "
+ @drop="
+ !track.locked
+ ? (track.inGroup ? onGroupChildDrop($event) : onTrackDrop($event))
+ : null
+ "
+ @dragleave="onTrackDragLeave"
>
@@ -5040,18 +5139,6 @@ onBeforeUnmount(() => {
.track.clip-track {
height: 34px;
- background: linear-gradient(
- to bottom,
- transparent 0%,
- rgba(110, 168, 255, 0.04) 50%,
- transparent 100%
- );
-}
-
-.clip-track .track-name {
- background: var(--panel-alt);
- font-size: 11px;
- color: var(--text-dim);
}
.clip-lane {
@@ -5061,18 +5148,7 @@ onBeforeUnmount(() => {
/* ---- Script track ---- */
.track.script-track {
- height: 26px;
- background: linear-gradient(
- to bottom,
- rgba(245, 158, 11, 0.06) 0%,
- rgba(245, 158, 11, 0.02) 100%
- );
-}
-
-.script-track .track-name {
- background: var(--panel-alt);
- font-size: 11px;
- color: var(--text-dim);
+ height: 30px;
}
.script-lane {
@@ -5115,17 +5191,6 @@ onBeforeUnmount(() => {
.track.audio-track {
height: 30px;
- background: linear-gradient(
- to bottom,
- color-mix(in srgb, var(--track-color, #0ea5e9) 8%, transparent) 0%,
- color-mix(in srgb, var(--track-color, #0ea5e9) 2%, transparent) 100%
- );
-}
-
-.audio-track .track-name {
- background: var(--panel-alt);
- font-size: 11px;
- color: var(--text-dim);
}
.audio-track.muted .track-name .track-label {
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index 2a720e8..aa1df5c 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -2950,6 +2950,63 @@ export function registerAllTests(): void {
const inG2 = g2.childIds.filter((id) => id === l.id).length
expect(inG1 + inG2).toBe(1)
})
+
+ test('moveTrackIntoGroup accepts script tracks as children', () => {
+ const s = makeFreshStore()
+ const st = s.addScriptTrack('Logic')
+ const g = s.addTrackGroup('G')
+ s.moveTrackIntoGroup(st.id, g.id)
+ expect(g.childIds).toContain(st.id)
+ expect((s.scene.trackOrder ?? []).includes(st.id)).toBe(false)
+ })
+
+ test('moveTrackIntoGroup accepts audio tracks as children', () => {
+ const s = makeFreshStore()
+ const at = s.addAudioTrack('FX')
+ const g = s.addTrackGroup('G')
+ s.moveTrackIntoGroup(at.id, g.id)
+ expect(g.childIds).toContain(at.id)
+ expect((s.scene.trackOrder ?? []).includes(at.id)).toBe(false)
+ })
+
+ test('group color cascades to script + audio children alongside layers', () => {
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'Mix', width: 10, height: 10, fill: '#000' })
+ const l = s.addLayer(a.id)!
+ const st = s.addScriptTrack('Script')
+ const at = s.addAudioTrack('Audio')
+ const g = s.addTrackGroup('G')
+ s.setTrackColor(g.id, '#ff8800')
+ s.moveTrackIntoGroup(l.id, g.id)
+ s.moveTrackIntoGroup(st.id, g.id)
+ s.moveTrackIntoGroup(at.id, g.id)
+ expect(l.color).toBe('#ff8800')
+ expect(st.color).toBe('#ff8800')
+ expect(at.color).toBe('#ff8800')
+ // None of them were explicitly recoloured — all inherit, none pin.
+ expect(l.colorOverridden).toBe(undefined)
+ expect(st.colorOverridden).toBe(undefined)
+ expect(at.colorOverridden).toBe(undefined)
+ })
+
+ test('normalizeScene preserves script + audio tracks as group children', () => {
+ // Legacy loads used to strip anything other than layers / clip
+ // tracks from childIds. All four non-group kinds must survive
+ // the roundtrip now.
+ const s = makeFreshStore()
+ const st = s.addScriptTrack('S')
+ const at = s.addAudioTrack('A')
+ const g = s.addTrackGroup('G')
+ const snap = JSON.parse(JSON.stringify(s.scene))
+ snap.groups = [{ id: g.id, name: 'G', childIds: [st.id, at.id] }]
+ snap.trackOrder = [g.id]
+ s.loadScene(snap)
+ const loaded = s.scene.groups?.find((x) => x.id === g.id)!
+ expect(loaded.childIds).toContain(st.id)
+ expect(loaded.childIds).toContain(at.id)
+ expect((s.scene.trackOrder ?? []).includes(st.id)).toBe(false)
+ expect((s.scene.trackOrder ?? []).includes(at.id)).toBe(false)
+ })
})
suite('scene store — duplicateTrack', () => {
@@ -5022,6 +5079,34 @@ export function registerAllTests(): void {
handle.unmount()
})
+ test('audio track name carries the draggable attribute so it joins drag-reorder', async () => {
+ // Audio tracks used to render with no @dragstart, which left them
+ // outside the stack-reorder flow. They now share the same hooks
+ // as layer / clip / script tracks.
+ const s = makeFreshStore()
+ s.addAudioTrack('Draggable')
+ const handle = mountComponent(Timeline, {})
+ await nextTick()
+ const name = handle.host.querySelector('.audio-track .track-name') as HTMLElement
+ expect(name).toBeTruthy()
+ expect(name.getAttribute('draggable')).toBe('true')
+ handle.unmount()
+ })
+
+ test('script track matches the default 30px track height', async () => {
+ // Regression: scripts used to be 26px, now unified with the other
+ // kinds so the timeline stack reads as a single visual list.
+ const s = makeFreshStore()
+ s.addScriptTrack('S')
+ const handle = mountComponent(Timeline, {})
+ await nextTick()
+ const st = handle.host.querySelector('.script-track') as HTMLElement
+ expect(st).toBeTruthy()
+ const h = getComputedStyle(st).height
+ expect(h).toBe('30px')
+ handle.unmount()
+ })
+
test('audio track co-exists with other track kinds in the tracks container', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#111' })
diff --git a/src/stores/scene.ts b/src/stores/scene.ts
index 6319a5a..4325c05 100644
--- a/src/stores/scene.ts
+++ b/src/stores/scene.ts
@@ -314,11 +314,16 @@ function normalizeScenePage(page: ScenePage): ScenePage {
for (const st of page.scriptTracks) known.add(st.id)
for (const at of page.audioTracks) known.add(at.id)
- const layerAndClipIds = new Set()
- for (const l of page.layers) layerAndClipIds.add(l.id)
- for (const t of page.clipTracks) layerAndClipIds.add(t.id)
+ // Any non-group track may live inside a group — layers, clip tracks,
+ // script tracks and audio tracks are all first-class citizens. Groups
+ // themselves stay at the top level (no nested groups).
+ const groupableIds = new Set()
+ for (const l of page.layers) groupableIds.add(l.id)
+ for (const t of page.clipTracks) groupableIds.add(t.id)
+ for (const st of page.scriptTracks) groupableIds.add(st.id)
+ for (const at of page.audioTracks) groupableIds.add(at.id)
for (const g of page.groups) {
- g.childIds = g.childIds.filter((id) => layerAndClipIds.has(id))
+ g.childIds = g.childIds.filter((id) => groupableIds.has(id))
}
const inGroup = new Set()
for (const g of page.groups) {
@@ -330,25 +335,29 @@ function normalizeScenePage(page: ScenePage): ScenePage {
}
page.trackOrder = page.trackOrder.filter((id) => known.has(id))
+ // Fallback for legacy / corrupted scenes where some ids are known but
+ // absent from trackOrder. Layers prepend (historically stage content
+ // sits on top); everything else appends in DOM order with no
+ // per-kind segregation — users can reorder freely afterwards.
+ const inOrder = new Set(page.trackOrder)
for (const l of page.layers) {
- if (!inGroup.has(l.id) && !page.trackOrder.includes(l.id)) {
+ if (!inGroup.has(l.id) && !inOrder.has(l.id)) {
page.trackOrder.unshift(l.id)
+ inOrder.add(l.id)
}
}
- for (const t of page.clipTracks) {
- if (!inGroup.has(t.id) && !page.trackOrder.includes(t.id)) {
- page.trackOrder.push(t.id)
+ const tail: Array<{ id: string }> = [
+ ...page.clipTracks,
+ ...page.scriptTracks,
+ ...page.audioTracks,
+ ...page.groups,
+ ]
+ for (const item of tail) {
+ if (!inGroup.has(item.id) && !inOrder.has(item.id)) {
+ page.trackOrder.push(item.id)
+ inOrder.add(item.id)
}
}
- for (const g of page.groups) {
- if (!page.trackOrder.includes(g.id)) page.trackOrder.push(g.id)
- }
- for (const st of page.scriptTracks) {
- if (!page.trackOrder.includes(st.id)) page.trackOrder.push(st.id)
- }
- for (const at of page.audioTracks) {
- if (!page.trackOrder.includes(at.id)) page.trackOrder.push(at.id)
- }
// Orphan audio clips (pointing at a missing track or missing asset) would
// never render anyway — drop them so serialised projects stay clean.
const audioTrackIds = new Set(page.audioTracks.map((t) => t.id))
@@ -2746,12 +2755,17 @@ export const useSceneStore = defineStore('scene', {
// Inherit the target group's color when the moved track hasn't
// been explicitly recoloured by the user. Matches the cascade
// behaviour of setTrackColor so a track joining a coloured group
- // visually lines up with its siblings.
+ // visually lines up with its siblings — applies to all four
+ // groupable kinds, not just layers + clip tracks.
if (target.color != null) {
const l = this.scene.layers.find((x) => x.id === trackId)
if (l && !l.colorOverridden) l.color = target.color
const ct = this.scene.clipTracks?.find((x) => x.id === trackId)
if (ct && !ct.colorOverridden) ct.color = target.color
+ const st = this.scene.scriptTracks?.find((x) => x.id === trackId)
+ if (st && !st.colorOverridden) st.color = target.color
+ const at = this.scene.audioTracks?.find((x) => x.id === trackId)
+ if (at && !at.colorOverridden) at.color = target.color
}
},
moveTrackOutOfGroup(trackId: string, toTopLevelIndex?: number) {
diff --git a/src/version.ts b/src/version.ts
index 81c3320..429044c 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
-export const APP_VERSION = '0.12.0'
+export const APP_VERSION = '0.13.0'
export const APP_NAME = 'RUWIO'
|