document.removeEventListener('keydown', onKeyDown))
backdrop-filter: blur(2px);
}
+.backdrop.topmost {
+ z-index: 1000;
+}
+
.dialog {
background: var(--panel);
border: 1px solid var(--border-strong);
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index 1ecff7b..dd14a5b 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -1365,28 +1365,26 @@ export function registerAllTests(): void {
// Silence actual file downloads during the test.
const originalClick = HTMLAnchorElement.prototype.click
HTMLAnchorElement.prototype.click = function () {}
-
const baseline = snapshotDialogs()
const handle = mountComponent(ExportDialog, { modelValue: true })
- await nextTick()
- const dialog = dialogsAddedSince(baseline)[0]
- const btn = findOptionByTitle(dialog, i18nT('export.apngPreviewTitle'))
- expect(btn).toBeTruthy()
- btn!.click()
- await nextTick()
- // Progress view replaces the options list — `.rendering` appears,
- // `.opt` buttons vanish.
- const rendering = dialog.querySelector('.rendering') as HTMLElement | null
- expect(rendering).toBeTruthy()
- const label = dialog.querySelector('.r-text')?.textContent ?? ''
- expect(label).toBe(i18nT('export.labelApngPreview'))
- // Abort the pending render so the encoder doesn't burn CPU for
- // the rest of the test run.
- const cancelBtn = dialog.querySelector('.r-actions button') as HTMLButtonElement | null
- cancelBtn?.click()
- await nextTick()
- HTMLAnchorElement.prototype.click = originalClick
- handle.unmount()
+ try {
+ await nextTick()
+ const dialog = dialogsAddedSince(baseline)[0]
+ const btn = findOptionByTitle(dialog, i18nT('export.apngPreviewTitle'))
+ expect(btn).toBeTruthy()
+ btn!.click()
+ await nextTick()
+ const rendering = dialog.querySelector('.rendering') as HTMLElement | null
+ expect(rendering).toBeTruthy()
+ const label = dialog.querySelector('.r-text')?.textContent ?? ''
+ expect(label).toBe(i18nT('export.labelApngPreview'))
+ const cancelBtn = dialog.querySelector('.r-actions button') as HTMLButtonElement | null
+ cancelBtn?.click()
+ await nextTick()
+ } finally {
+ HTMLAnchorElement.prototype.click = originalClick
+ handle.unmount()
+ }
})
})
@@ -2368,40 +2366,35 @@ export function registerAllTests(): void {
await saveProjectToStore(s.project, { id: idA, name: 'A' })
await tick(5)
await saveProjectToStore(s.project, { id: idB, name: 'B' })
-
const baseline = snapshotDialogs()
const handle = mountComponent(WelcomeDialog, { modelValue: true })
- // Wait for the dialog's async reload() to populate the list.
- await waitUntil(() => {
- const d = dialogsAddedSince(baseline)[0]
- return !!d && d.querySelectorAll('.recent').length >= 2
- })
- const dialog = dialogsAddedSince(baseline)[0]
- expect(dialog).toBeTruthy()
- const items = dialog.querySelectorAll('.recent') as NodeListOf
- expect(items.length >= 2).toBe(true)
+ try {
+ await waitUntil(() => {
+ const d = dialogsAddedSince(baseline)[0]
+ return !!d && d.querySelectorAll('.recent').length >= 2
+ })
+ const dialog = dialogsAddedSince(baseline)[0]
+ expect(dialog).toBeTruthy()
+ const items = dialog.querySelectorAll('.recent') as NodeListOf
+ expect(items.length >= 2).toBe(true)
- // The initial focus should already be on the first recent.
- items[0].focus()
- expect(document.activeElement).toBe(items[0])
-
- // ArrowDown moves to the next item.
- items[0].dispatchEvent(
- new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
- )
- await nextTick()
- expect(document.activeElement).toBe(items[1])
-
- // ArrowUp moves back.
- items[1].dispatchEvent(
- new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
- )
- await nextTick()
- expect(document.activeElement).toBe(items[0])
-
- handle.unmount()
- await deleteStoredProject(idA)
- await deleteStoredProject(idB)
+ items[0].focus()
+ expect(document.activeElement).toBe(items[0])
+ items[0].dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
+ )
+ await nextTick()
+ expect(document.activeElement).toBe(items[1])
+ items[1].dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
+ )
+ await nextTick()
+ expect(document.activeElement).toBe(items[0])
+ } finally {
+ handle.unmount()
+ await deleteStoredProject(idA).catch(() => {})
+ await deleteStoredProject(idB).catch(() => {})
+ }
})
test('Home and End jump to the first / last recent', async () => {
@@ -2415,34 +2408,236 @@ export function registerAllTests(): void {
await saveProjectToStore(s.project, { id, name: id })
await tick(5)
}
-
const baseline = snapshotDialogs()
const handle = mountComponent(WelcomeDialog, { modelValue: true })
- await waitUntil(() => {
- const d = dialogsAddedSince(baseline)[0]
- return !!d && d.querySelectorAll('.recent').length >= 3
+ try {
+ await waitUntil(() => {
+ const d = dialogsAddedSince(baseline)[0]
+ return !!d && d.querySelectorAll('.recent').length >= 3
+ })
+ const dialog = dialogsAddedSince(baseline)[0]
+ const items = dialog.querySelectorAll('.recent') as NodeListOf
+ expect(items.length >= 3).toBe(true)
+
+ items[1].focus()
+ items[1].dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'End', bubbles: true }),
+ )
+ await nextTick()
+ expect(document.activeElement).toBe(items[items.length - 1])
+ items[items.length - 1].dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Home', bubbles: true }),
+ )
+ await nextTick()
+ expect(document.activeElement).toBe(items[0])
+ } finally {
+ handle.unmount()
+ for (const id of ids) await deleteStoredProject(id).catch(() => {})
+ }
+ })
+
+ test('Enter on a focused recent opens the project (when no confirm is up)', async () => {
+ // Regression guard: Enter must open the focused project while
+ // the welcome is the active modal. Delete-confirm (next test)
+ // verifies that Enter doesn't leak through to this handler while
+ // the confirm is on top.
+ const s = makeFreshStore()
+ const idA = `test-enter-a-${Date.now()}`
+ await saveProjectToStore(s.project, { id: idA, name: 'Open me' })
+ const baseline = snapshotDialogs()
+ let opened: { name: string } | null = null
+ const handle = mountComponent(WelcomeDialog, {
+ modelValue: true,
+ 'onOpen': (p: { name: string }) => {
+ opened = p
+ },
})
- const dialog = dialogsAddedSince(baseline)[0]
- const items = dialog.querySelectorAll('.recent') as NodeListOf
- expect(items.length >= 3).toBe(true)
+ try {
+ await waitUntil(() => {
+ const d = dialogsAddedSince(baseline)[0]
+ return !!d && d.querySelectorAll('.recent').length >= 1
+ })
+ const welcome = dialogsAddedSince(baseline)[0]
+ const top = welcome.querySelectorAll('.recent')[0] as HTMLElement
+ top.focus()
+ top.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
+ )
+ await nextTick()
+ expect(opened).toBeTruthy()
+ } finally {
+ handle.unmount()
+ await deleteStoredProject(idA).catch(() => {})
+ }
+ })
- // Start somewhere in the middle, then End jumps to the last.
- items[1].focus()
- items[1].dispatchEvent(
- new KeyboardEvent('keydown', { key: 'End', bubbles: true }),
- )
- await nextTick()
- expect(document.activeElement).toBe(items[items.length - 1])
+ test('Enter while the delete-confirm is up triggers confirm (not openProject)', async () => {
+ // After Backspace pops the confirm, focus must move to the
+ // confirm button so the next Enter fires its click instead of
+ // bubbling back to the welcome row and opening the project.
+ const s = makeFreshStore()
+ const idA = `test-enter-confirm-a-${Date.now()}`
+ const idB = `test-enter-confirm-b-${Date.now()}`
+ await saveProjectToStore(s.project, { id: idA, name: 'Keep' })
+ await tick(5)
+ await saveProjectToStore(s.project, { id: idB, name: 'DropMe' })
+ const baseline = snapshotDialogs()
+ let openCalls = 0
+ const handle = mountComponent(WelcomeDialog, {
+ modelValue: true,
+ 'onOpen': () => {
+ openCalls += 1
+ },
+ })
+ try {
+ await waitUntil(() => {
+ const d = dialogsAddedSince(baseline)[0]
+ return !!d && d.querySelectorAll('.recent').length >= 2
+ })
+ const welcome = dialogsAddedSince(baseline)[0]
+ // The newest seed ('DropMe' / idB) is at the top of the list.
+ // We assert by name rather than by count because the browser's
+ // IDB may already hold MAX_RECENT real projects; in that case
+ // deleting the top entry just unmasks a hidden record and the
+ // count stays the same even though the seed is gone.
+ const top = welcome.querySelectorAll('.recent')[0] as HTMLElement
+ top.focus()
+ top.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }),
+ )
+ await waitUntil(() => !!document.querySelector('.backdrop.topmost'))
+ // Confirm dialog is up; focus should now be on its primary
+ // button so Enter fires the confirm instead of bubbling back
+ // to the welcome row's Enter handler (which would openProject).
+ const topmost = document.querySelector('.backdrop.topmost') as HTMLElement
+ const primary = topmost.querySelector('footer .btn.primary') as HTMLButtonElement
+ await waitUntil(() => document.activeElement === primary)
+ expect(document.activeElement).toBe(primary)
+ primary.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
+ )
+ // Native browser behaviour: Enter on a focused button synthesises
+ // a click. Simulate that click directly to keep jsdom/Chrome
+ // behaviour consistent inside the test.
+ primary.click()
+ await waitUntil(() => !welcome.textContent?.includes('DropMe'))
+ expect(welcome.textContent?.includes('DropMe')).toBe(false)
+ // The welcome's openProject handler must NOT have fired.
+ expect(openCalls).toBe(0)
+ } finally {
+ handle.unmount()
+ await deleteStoredProject(idA).catch(() => {})
+ await deleteStoredProject(idB).catch(() => {})
+ }
+ })
- // Home returns to the first.
- items[items.length - 1].dispatchEvent(
- new KeyboardEvent('keydown', { key: 'Home', bubbles: true }),
- )
- await nextTick()
- expect(document.activeElement).toBe(items[0])
+ test('Backspace on a focused recent opens the confirm and removes it on OK', async () => {
+ // Seed two recents; mount welcome; focus the top one; press
+ // Backspace. A confirm dialog is expected to pop (topmost layer),
+ // and clicking OK should drop the entry from the welcome list.
+ const s = makeFreshStore()
+ const idA = `test-backspace-a-${Date.now()}`
+ const idB = `test-backspace-b-${Date.now()}`
+ await saveProjectToStore(s.project, { id: idA, name: 'Keep' })
+ await tick(5)
+ await saveProjectToStore(s.project, { id: idB, name: 'DropMe' })
+ const baseline = snapshotDialogs()
+ const handle = mountComponent(WelcomeDialog, { modelValue: true })
+ try {
+ await waitUntil(() => {
+ const d = dialogsAddedSince(baseline)[0]
+ return !!d && d.querySelectorAll('.recent').length >= 2
+ })
+ const welcome = dialogsAddedSince(baseline)[0]
+ // Assert by seed name rather than count — MAX_RECENT caps the
+ // visible list at 8, so if the browser's IDB already has enough
+ // real projects, deleting the top seed just unmasks a hidden
+ // record and the count does not drop.
+ const top = welcome.querySelectorAll('.recent')[0] as HTMLElement
+ top.focus()
+ expect(document.activeElement).toBe(top)
+ top.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }),
+ )
+ await waitUntil(() => !!document.querySelector('.backdrop.topmost'))
+ const topmost = document.querySelector('.backdrop.topmost') as HTMLElement
+ const confirmBtn = topmost.querySelector('footer .btn.primary') as HTMLButtonElement
+ expect(confirmBtn).toBeTruthy()
+ confirmBtn.click()
+ // reload() briefly flips loading=true, which hides .recents
+ // behind the .empty "loading…" placeholder and also unmounts
+ // the s (so 'DropMe' is transiently absent from textContent
+ // too). Wait for the list to fully settle before asserting.
+ await waitUntil(
+ () =>
+ !welcome.textContent?.includes('DropMe') &&
+ !!welcome.querySelector('.recents'),
+ )
+ expect(welcome.textContent?.includes('DropMe')).toBe(false)
+ // Focus is placed by deleteRecentWithConfirm AFTER reload
+ // returns, so poll until it lands inside welcome.
+ await waitUntil(() => welcome.contains(document.activeElement))
+ expect(welcome.contains(document.activeElement)).toBe(true)
+ } finally {
+ handle.unmount()
+ await deleteStoredProject(idA).catch(() => {})
+ await deleteStoredProject(idB).catch(() => {})
+ }
+ })
- handle.unmount()
- for (const id of ids) await deleteStoredProject(id)
+ test('delete confirm floats above the welcome dialog and the list updates afterwards', async () => {
+ // Reproduces: clicking the trash icon used to pop the confirm
+ // behind welcome (both z-index 900); pressing ESC then dismissed
+ // welcome as well because both modals ate the key. The topmost
+ // flag fixes stacking and the stack-based ESC filter keeps the
+ // confirm from dragging welcome down with it.
+ const s = makeFreshStore()
+ const idA = `test-del-a-${Date.now()}`
+ const idB = `test-del-b-${Date.now()}`
+ await saveProjectToStore(s.project, { id: idA, name: 'Keep' })
+ await tick(5)
+ await saveProjectToStore(s.project, { id: idB, name: 'DropMe' })
+ const baseline = snapshotDialogs()
+ const handle = mountComponent(WelcomeDialog, { modelValue: true })
+ try {
+ await waitUntil(() => {
+ const d = dialogsAddedSince(baseline)[0]
+ return !!d && d.querySelectorAll('.recent').length >= 2
+ })
+ const welcome = dialogsAddedSince(baseline)[0]
+ expect(welcome).toBeTruthy()
+ expect(welcome.querySelectorAll('.recent').length >= 2).toBe(true)
+
+ const target = welcome.querySelectorAll('.recent')[0] as HTMLElement
+ const delBtn = target.querySelector('.del') as HTMLButtonElement
+ expect(delBtn).toBeTruthy()
+ delBtn.click()
+ await waitUntil(() => !!document.querySelector('.backdrop.topmost'))
+ const topmost = document.querySelector('.backdrop.topmost') as HTMLElement
+ expect(topmost).toBeTruthy()
+ const confirmBtn = topmost.querySelector(
+ 'footer .btn.primary',
+ ) as HTMLButtonElement
+ expect(confirmBtn).toBeTruthy()
+ confirmBtn.click()
+ // Assert by seed name rather than count — MAX_RECENT=8 caps the
+ // visible list, so deleting the top seed may just unmask a
+ // hidden record instead of dropping the total. Also wait for
+ // the list to rerender: reload() briefly flips loading=true,
+ // which hides .recents behind the "loading…" placeholder.
+ await waitUntil(
+ () =>
+ !welcome.textContent?.includes('DropMe') &&
+ !!welcome.querySelector('.recents'),
+ )
+ expect(welcome.textContent?.includes('DropMe')).toBe(false)
+ expect(welcome.isConnected).toBe(true)
+ expect(welcome.querySelector('.recents')).toBeTruthy()
+ } finally {
+ handle.unmount()
+ await deleteStoredProject(idA).catch(() => {})
+ await deleteStoredProject(idB).catch(() => {})
+ }
})
})
@@ -4106,6 +4301,129 @@ export function registerAllTests(): void {
})
})
+ suite('svg split', () => {
+ // A minimal SVG with two non-overlapping paths. Absolute
+ // coordinates so getBBox returns predictable values under jsdom/
+ // Chrome. The split helper should emit two standalone SVG parts.
+ const sampleSvg = ``
+
+ function toDataUrl(svg: string): string {
+ return 'data:image/svg+xml,' + encodeURIComponent(svg)
+ }
+
+ test('splitSvg returns one part per top-level drawable', async () => {
+ const { splitSvg } = await import('@/lib/svgSplit')
+ const out = splitSvg(sampleSvg)
+ expect(out).toBeTruthy()
+ expect(out!.parts.length).toBe(2)
+ expect(out!.parts[0].svgText).toContain(' {
+ const { splitSvg } = await import('@/lib/svgSplit')
+ const one = ``
+ expect(splitSvg(one)).toBeNull()
+ })
+
+ test('splitSvg data URLs keep # encoded so hex colours survive (regression)', async () => {
+ // A raw `#` in the data URL is parsed as a fragment separator
+ // by every browser, which truncates the SVG at the first colour
+ // value. The encoder has to leave `#` percent-encoded even
+ // though it makes the URL a bit longer.
+ const { splitSvg } = await import('@/lib/svgSplit')
+ const out = splitSvg(sampleSvg)
+ expect(out).toBeTruthy()
+ for (const p of out!.parts) {
+ // The payload portion (after the comma) must NOT contain a
+ // literal `#` — otherwise browsers load a truncated SVG.
+ const payload = p.dataUrl.slice(p.dataUrl.indexOf(',') + 1)
+ expect(payload.includes('#')).toBe(false)
+ // It should still encode the hex colour:
+ expect(payload.includes('%23')).toBe(true)
+ }
+ })
+
+ test('splitLayerSvg replaces the original layer with one layer per part at the same stage position', () => {
+ const s = makeFreshStore()
+ const asset = s.addAsset({
+ kind: 'image',
+ name: 'SvgSrc',
+ src: toDataUrl(sampleSvg),
+ width: 100,
+ height: 60,
+ })
+ const l = s.addLayer(asset.id)!
+ const origX = l.base.x
+ const origY = l.base.y
+ const result = s.splitLayerSvg(l.id)
+ expect(result).toBeTruthy()
+ expect(result!.length).toBe(2)
+ // Original layer is gone.
+ expect(s.scene.layers.find((x) => x.id === l.id)).toBe(undefined)
+ // Both new layers exist and sit on top of the old layer's stage
+ // position — each part's SVG canvas is the same size as the
+ // original so no delta is needed.
+ const [a, b] = result!.map((id) => s.scene.layers.find((x) => x.id === id)!)
+ expect(a.base.x).toBe(origX)
+ expect(a.base.y).toBe(origY)
+ expect(b.base.x).toBe(origX)
+ expect(b.base.y).toBe(origY)
+ // New assets carry the full original dimensions.
+ const assetA = s.scene.assets.find(
+ (x) => x.id === a.assetId,
+ ) as Extract
+ expect(assetA.width).toBe(100)
+ expect(assetA.height).toBe(60)
+ // New parts replace the original in trackOrder at its original slot.
+ const order = s.scene.trackOrder ?? []
+ expect(order.includes(l.id)).toBe(false)
+ for (const id of result!) expect(order.includes(id)).toBe(true)
+ })
+
+ test('splitLayerSvg clones keyframes verbatim (no x/y drift)', () => {
+ const s = makeFreshStore()
+ const asset = s.addAsset({
+ kind: 'image',
+ name: 'Anim',
+ src: toDataUrl(sampleSvg),
+ width: 100,
+ height: 60,
+ })
+ const l = s.addLayer(asset.id)!
+ s.upsertKeyframe(l.id, 1, { x: 200, y: 120 })
+ const result = s.splitLayerSvg(l.id)
+ expect(result).toBeTruthy()
+ const first = s.scene.layers.find((x) => x.id === result![0])!
+ const kf = first.keyframes.find((k) => Math.abs(k.time - 1) < 1e-3)
+ expect(kf).toBeTruthy()
+ // Same x/y as the source keyframe; no shift math to go wrong.
+ expect(kf!.props.x).toBe(200)
+ expect(kf!.props.y).toBe(120)
+ })
+
+ test('splitLayerSvg returns null for a non-SVG image layer', () => {
+ const s = makeFreshStore()
+ const asset = s.addAsset({
+ kind: 'image',
+ name: 'Raster',
+ src: 'data:image/png;base64,iVBORw0K==',
+ width: 40,
+ height: 40,
+ })
+ const l = s.addLayer(asset.id)!
+ expect(s.splitLayerSvg(l.id)).toBeNull()
+ })
+ })
+
suite('polygon — store actions', () => {
function makePolygonLayer() {
const s = makeFreshStore()
@@ -5761,6 +6079,84 @@ export function registerAllTests(): void {
})
})
+ suite('ui — Timeline split-svg menu entry', () => {
+ const sampleSvg = ``
+
+ function toDataUrl(svg: string): string {
+ return 'data:image/svg+xml,' + encodeURIComponent(svg)
+ }
+
+ test('Split SVG entry appears in the track menu for an SVG-backed layer', async () => {
+ const s = makeFreshStore()
+ const asset = s.addAsset({
+ kind: 'image',
+ name: 'Parts',
+ src: toDataUrl(sampleSvg),
+ width: 40,
+ height: 40,
+ })
+ s.addLayer(asset.id)
+ const handle = mountComponent(Timeline, {})
+ await nextTick()
+ // Layer tracks render as `.track` without a kind-specific suffix;
+ // the first track-name in the host is the layer we just added.
+ const name = handle.host.querySelector('.track .track-name') as HTMLElement | null
+ expect(name).toBeTruthy()
+ name!.dispatchEvent(
+ new MouseEvent('contextmenu', {
+ bubbles: true,
+ cancelable: true,
+ clientX: 20,
+ clientY: 20,
+ }),
+ )
+ await nextTick()
+ const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
+ const menu = menus[menus.length - 1]
+ expect(menu).toBeTruthy()
+ const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
+ const hasSplit = items.some((b) => b.textContent?.includes(i18nT('timeline.splitSvg')))
+ expect(hasSplit).toBe(true)
+ handle.unmount()
+ })
+
+ test('Split SVG entry is absent for a raster (PNG) layer', async () => {
+ const s = makeFreshStore()
+ const asset = s.addAsset({
+ kind: 'image',
+ name: 'Raster',
+ src: 'data:image/png;base64,iVBORw0K==',
+ width: 40,
+ height: 40,
+ })
+ s.addLayer(asset.id)
+ const handle = mountComponent(Timeline, {})
+ await nextTick()
+ // Layer tracks render as `.track` without a kind-specific suffix;
+ // the first track-name in the host is the layer we just added.
+ const name = handle.host.querySelector('.track .track-name') as HTMLElement | null
+ expect(name).toBeTruthy()
+ name!.dispatchEvent(
+ new MouseEvent('contextmenu', {
+ bubbles: true,
+ cancelable: true,
+ clientX: 20,
+ clientY: 20,
+ }),
+ )
+ await nextTick()
+ const menus = Array.from(handle.host.querySelectorAll('.kf-menu')) as HTMLElement[]
+ const menu = menus[menus.length - 1]
+ const items = Array.from(menu.querySelectorAll('.kf-menu-item')) as HTMLButtonElement[]
+ const hasSplit = items.some((b) => b.textContent?.includes(i18nT('timeline.splitSvg')))
+ expect(hasSplit).toBe(false)
+ 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/i18n/messages.ts b/src/i18n/messages.ts
index 8b37207..9562165 100644
--- a/src/i18n/messages.ts
+++ b/src/i18n/messages.ts
@@ -294,6 +294,7 @@ export const en = {
separate: 'Separate Position and Rotation',
rotation: 'Rotation',
position: 'Position',
+ splitSvg: 'Split SVG into paths',
removeInstanceDialog: {
title: 'Remove clip instance?',
message: 'Remove "{name}" at {time}s from the timeline.',
diff --git a/src/lib/svgSplit.ts b/src/lib/svgSplit.ts
new file mode 100644
index 0000000..3e6b177
--- /dev/null
+++ b/src/lib/svgSplit.ts
@@ -0,0 +1,150 @@
+/**
+ * Split an SVG document into its top-level drawable children. Each
+ * returned part carries its own standalone SVG (viewBox shrunk to the
+ * part's bounding box) + a pre-computed data URL suitable for use as
+ * an image asset. Intended for the timeline's "Split SVG" action,
+ * which replaces a single SVG-backed layer with one layer per part.
+ *
+ * Top-level = a direct child of the root