0.8.0: group colour cascade, still-frame past end, project rename, UX polish

Headline:

- Group colour cascades to every non-overridden child (layer or clip
  track). New `colorOverridden` flag on Layer / ClipTrack / ScriptTrack
  / TrackGroup; setTrackColor on a non-group pins the track, clearing
  re-inherits from the parent group. moveTrackIntoGroup also inherits.
  normalizeScene migrates legacy scenes (existing colour ⇒ overridden).
- Keyframes inside a button-asset timeline snap to the 4 state slots
  (normal/hover/pressed/disabled). upsertKeyframe / setKeyframeTime /
  duplicateKeyframe all enforce it; colliding drags refuse the move,
  duplicates walk to the nearest free slot.
- Backspace / Delete on a selected layer removes it (respecting the
  layer's own lock and any enclosing group lock).
- "Still frame" allowed on the last keyframe: the layer stays on stage
  past its final kf. Timeline draws a solid dim tail line from the
  last kf to the end, and single-keyframe tracks now show a full-span
  line so on-stage/off-stage reads at a glance. Height stays 3px.
- Project name moved next to the logo, turns into a rename button on
  hover (pencil fades in). Rename syncs the stored record when the
  project is already persisted.
- Shadow + blur keyframes animate in the preview and exported runtime:
  the runtime's lerpProps used to drop them, so every preview rendered
  shadow=0 between keyframes. RUNTIME_VERSION 11 → 13 (covers the
  button-mask + shadow fixes). Held-last-kf also taught to the runtime.
- Button masks render across every path: edit-mode mask-preview,
  nested buttons on the main stage, sidebar thumbnails, fullscreen
  preview + exports. drawButtonFrame new in render.ts.

Smaller fixes:

- Dragging layer A on the stage with a stale selection on layer B used
  to move B and snap A back. Now the shift-selected branch only runs
  when the dragged layer is itself in the selection.
- Clip-track row was missing its icon — PhFilmStrip was referenced but
  never imported. Added, plus dropped the bespoke `gap` on clip / script
  name rows so their icon+label spacing matches layer rows.
- Clip-art drawer pre-lays-out its 400px grid behind `overflow: hidden`
  so slide-in reveals a finished grid instead of reflowing auto-fill
  columns over 220ms.
- Timeline clip-track dropzone hides until the library has a clip; its
  "drop a clip here" hint stays centred in the visible ruler area.
- Keyframe context menu: check-mark and icons share a single 12px column
  so the ✓ on an easing option lines up with PhPauseCircle / PhCopy /
  PhTrash on the rows below. "Still frame"'s active indicator moved to
  the trailing-check slot.
- Button editor's mask-preview now shows the button's own stage instead
  of leaking through to the main scene. drawButtonFrame drives it.

Regression tests cover all of the above: group-color cascade, pin,
clear-re-inherit, migration, button-kf snap and collision rules,
backspace-deletes-selected-layer (incl. lock guards + editable-target
guard), still-frame-past-end visibility, clipart drawer rigid width,
mask rendering in drawButtonFrame / drawSceneFrame, shadow + blur
interpolation in both canvas and runtime paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-21 21:26:06 +02:00
1 parent ac89f6762d
commit 1e89ea86e0
15 files changed
+849 -74

No files matched your search

+461
View File
@@ -34,6 +34,7 @@ import Modal from '@/components/ui/Modal.vue'
import IconButton from '@/components/ui/IconButton.vue'
import Toast from '@/components/ui/Toast.vue'
import PropertiesPanel from '@/components/PropertiesPanel.vue'
import ClipartDrawer from '@/components/ClipartDrawer.vue'
import { expect, suite, test } from './testRunner'
interface MountedHandle {
@@ -462,6 +463,57 @@ export function registerAllTests(): void {
})
})
suite('interpolation — shadow properties', () => {
// Regression: shadow keyframes used to render static (or nothing) in
// the preview because the runtime's lerpProps dropped shadow fields.
// The canvas-side sampleLayer must interpolate them correctly; this
// pins the contract both paths share.
function rectWithShadow() {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Sh', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
// Seed both endpoints so there's a clear interpolation target.
s.upsertKeyframe(l.id, 0, {
shadowOpacity: 0,
shadowBlur: 0,
shadowDistance: 0,
shadowAngle: 0,
shadowColor: '#000000',
})
s.upsertKeyframe(l.id, 2, {
shadowOpacity: 1,
shadowBlur: 20,
shadowDistance: 10,
shadowAngle: 180,
shadowColor: '#ffffff',
})
return l
}
test('shadowOpacity lerps halfway between keyframes', () => {
const l = rectWithShadow()
const mid = sampleLayer(l, 1)
expect(mid.shadowOpacity).toBe(0.5)
})
test('shadowBlur / distance / angle lerp halfway between keyframes', () => {
const l = rectWithShadow()
const mid = sampleLayer(l, 1)
expect(mid.shadowBlur).toBe(10)
expect(mid.shadowDistance).toBe(5)
expect(mid.shadowAngle).toBe(90)
})
test('shadowColor is interpolated per channel (not snapped)', () => {
const l = rectWithShadow()
const mid = sampleLayer(l, 1)
// Black→white at t=0.5 lands on mid-gray (same lerpColor the
// fill-color test above pins).
expect(mid.shadowColor).toBe('#808080')
})
})
suite('interpolation — layer visibility (kf range)', () => {
test('untouched baseline (single kf @0) is always visible', () => {
const s = makeFreshStore()
@@ -509,6 +561,38 @@ export function registerAllTests(): void {
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 6)).toBe(false)
})
test('last kf held: layer stays visible past its last keyframe', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 5, { scaleX: 2 })
// Mark the t=5 kf as hold. It's the last keyframe — used to be a
// no-op; now it means "stay on stage past the end of the anim".
const last = l.keyframes.find((k) => k.time === 5)!
s.setKeyframeHold(l.id, last.id, true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 6)).toBe(true)
expect(isLayerActiveAt(l, 99)).toBe(true)
// sampleLayer already pins to last.props for time >= last.time —
// confirm the value is the last keyframe's, not interpolating out.
expect(sampleLayer(l, 10).scaleX).toBe(2)
})
test('non-last held kf does NOT extend visibility past end', () => {
// Hold on a middle keyframe only changes how it interpolates TO
// the following kf; it should not keep the layer alive past the
// final keyframe when that final kf itself isn't held.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 2, { scaleX: 2 })
s.upsertKeyframe(l.id, 5, { scaleX: 3 })
const middle = l.keyframes.find((k) => k.time === 2)!
s.setKeyframeHold(l.id, middle.id, true)
expect(isLayerActiveAt(l, 5)).toBe(true)
expect(isLayerActiveAt(l, 6)).toBe(false)
})
})
suite('timeline snap', () => {
@@ -586,6 +670,21 @@ export function registerAllTests(): void {
expect(src).toContain('AnimatrPlayer.mount')
expect(src).toContain('BundlR')
})
test('runtime lerpProps interpolates shadow + blur keyframes', () => {
// Regression: shadow properties (shadowBlur/Opacity/Angle/Distance/
// Color) and blur used to be dropped by the runtime's lerpProps,
// so an animation that fades a shadow in the editor played flat
// in the preview / exported HTML. The runtime must carry them
// through the per-pair interpolation.
const r = generateRuntimeScript()
expect(r).toContain('shadowBlur')
expect(r).toContain('shadowOpacity')
expect(r).toContain('shadowAngle')
expect(r).toContain('shadowDistance')
expect(r).toContain('shadowColor')
expect(r).toContain('out.blur')
})
})
suite('playback — transport', () => {
@@ -1457,6 +1556,109 @@ export function registerAllTests(): void {
expect(g.childIds).toEqual([l2.id, l1.id])
})
test('group color cascades to non-overridden children', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l1 = s.addLayer(a.id)!
const l2 = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l1.id, g.id)
s.moveTrackIntoGroup(l2.id, g.id)
s.setTrackColor(g.id, '#ff0000')
expect(l1.color).toBe('#ff0000')
expect(l2.color).toBe('#ff0000')
// The children inherited — they must NOT be marked as overridden.
expect(l1.colorOverridden).toBe(undefined)
expect(l2.colorOverridden).toBe(undefined)
})
test('explicit per-track color pins against later group-color changes', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l1 = s.addLayer(a.id)!
const l2 = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l1.id, g.id)
s.moveTrackIntoGroup(l2.id, g.id)
s.setTrackColor(g.id, '#ff0000')
// User recolours one child AFTER the group — it pins.
s.setTrackColor(l1.id, '#00ff00')
expect(l1.colorOverridden).toBe(true)
// Group colour changes again — pinned child ignores it; other follows.
s.setTrackColor(g.id, '#0000ff')
expect(l1.color).toBe('#00ff00')
expect(l2.color).toBe('#0000ff')
})
test('clearing a child-color (null) re-inherits the group color', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
s.setTrackColor(g.id, '#ff0000')
s.setTrackColor(l.id, '#00ff00')
expect(l.color).toBe('#00ff00')
s.setTrackColor(l.id, null)
// Now inheriting again.
expect(l.color).toBe('#ff0000')
expect(l.colorOverridden).toBe(undefined)
})
test('clearing a group color clears cascade on non-overridden children', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l1 = s.addLayer(a.id)!
const l2 = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l1.id, g.id)
s.moveTrackIntoGroup(l2.id, g.id)
s.setTrackColor(g.id, '#ff0000')
s.setTrackColor(l2.id, '#00ff00')
s.setTrackColor(g.id, null)
expect(l1.color).toBe(undefined)
// l2 was pinned, keeps its explicit colour.
expect(l2.color).toBe('#00ff00')
})
test('moving a track into a coloured group inherits its color', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.setTrackColor(g.id, '#ff0000')
s.moveTrackIntoGroup(l.id, g.id)
expect(l.color).toBe('#ff0000')
expect(l.colorOverridden).toBe(undefined)
})
test('moving an overridden track into a coloured group keeps its color', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.setTrackColor(l.id, '#00ff00')
const g = s.addTrackGroup('G')
s.setTrackColor(g.id, '#ff0000')
s.moveTrackIntoGroup(l.id, g.id)
expect(l.color).toBe('#00ff00')
})
test('loading an older scene treats pre-existing track colors as overridden', () => {
// Migration: scenes saved before colorOverridden shipped must
// preserve their per-track colour choices against future group
// cascades. normalizeScene stamps colorOverridden=true on any
// track that arrives with a colour and no flag.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const snap = JSON.parse(JSON.stringify(s.scene))
snap.layers[0].color = '#123456'
// legacy: no colorOverridden field on the layer
s.loadScene(snap)
const loaded = s.scene.layers.find((x) => x.id === l.id)!
expect(loaded.colorOverridden).toBe(true)
})
test('normalizeScene (via loadScene) removes orphan childIds and deduplicates', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'N', width: 10, height: 10, fill: '#666' })
@@ -1524,6 +1726,103 @@ export function registerAllTests(): void {
})
})
suite('timeline — backspace deletes selected layer', () => {
// Regression: Backspace / Delete used to only remove selected
// keyframes and markers. The selected layer track had no shortcut
// — the user had to use the context menu. Timeline's document-level
// keydown now also deletes the currently-selected layer, skipping
// locked layers (direct or via a locked parent group).
async function clearTimelineSelections() {
// The timeline's marker selection lives in a component-local ref
// that the store reset doesn't touch. Two Escape presses clear
// keyframes first, then markers — matches the handler's priority.
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
}
test('Backspace removes the selected layer and clears the selection', async () => {
const s = makeFreshStore()
await clearTimelineSelections()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
await nextTick()
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(false)
expect(s.selectedLayerId).toBe(null)
})
test('Delete also removes the selected layer', async () => {
const s = makeFreshStore()
await clearTimelineSelections()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete' }))
await nextTick()
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(false)
})
test('Backspace is a no-op while the layer itself is locked', async () => {
const s = makeFreshStore()
await clearTimelineSelections()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
s.setTrackLocked(l.id, true)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
await nextTick()
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
})
test('Backspace is a no-op while the parent group is locked', async () => {
const s = makeFreshStore()
await clearTimelineSelections()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
s.setTrackLocked(g.id, true)
s.selectLayer(l.id)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
await nextTick()
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
})
test('Backspace while typing in an input does not touch the selected layer', async () => {
const s = makeFreshStore()
await clearTimelineSelections()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
// Mount an input, focus it, dispatch Backspace FROM the input.
const input = document.createElement('input')
input.setAttribute('data-test-input', '')
document.body.appendChild(input)
input.focus()
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }))
await nextTick()
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
input.remove()
})
test('Backspace with no layer selected is a no-op', async () => {
const s = makeFreshStore()
await clearTimelineSelections()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
// Explicitly deselect
s.selectLayer(null)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
await nextTick()
expect(s.scene.layers.some((x) => x.id === l.id)).toBe(true)
})
})
suite('version — app metadata', () => {
test('APP_VERSION is a semver-like string', () => {
expect(typeof APP_VERSION === 'string').toBe(true)
@@ -2334,6 +2633,86 @@ export function registerAllTests(): void {
})
})
suite('clipart drawer — grid pre-layout', () => {
// Regression: the drawer's `.drawer-panel` used to be `v-if`-gated on
// clipart.open and given `flex: 1; min-width: 0` — which meant the
// grid mounted at the same moment the drawer started its width
// transition, so the `repeat(auto-fill, minmax(88px, 1fr))` grid
// reflowed continuously from ~1 column at 5px → 4 columns at 424px
// and the thumbs "tumbled" into place. The panel is now always
// mounted at a rigid 400px, and `.clipart-drawer { overflow: hidden }`
// just clips the slide.
test('.drawer-panel is in the DOM when the drawer is closed', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(false)
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const panel = handle.host.querySelector('.drawer-panel')
expect(panel).toBeTruthy()
handle.unmount()
})
test('.drawer-panel is in the DOM when the drawer is open', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(true)
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const panel = handle.host.querySelector('.drawer-panel')
expect(panel).toBeTruthy()
handle.unmount()
})
test('.drawer-panel stays mounted across open → closed toggle', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(true)
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
expect(handle.host.querySelector('.drawer-panel')).toBeTruthy()
s.setOpen(false)
await nextTick()
expect(handle.host.querySelector('.drawer-panel')).toBeTruthy()
handle.unmount()
})
test('.drawer-panel has a rigid 400px width regardless of the drawer state', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(false)
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const panel = handle.host.querySelector('.drawer-panel') as HTMLElement
expect(panel).toBeTruthy()
const widthClosed = getComputedStyle(panel).width
s.setOpen(true)
await nextTick()
const widthOpen = getComputedStyle(panel).width
expect(widthClosed).toBe('400px')
expect(widthOpen).toBe('400px')
handle.unmount()
})
test('.clipart-drawer clips its contents (overflow: hidden)', async () => {
const { useClipartStore } = await import('@/stores/clipart')
const s = useClipartStore()
s.setOpen(false)
const handle = mountComponent(ClipartDrawer, {})
await nextTick()
const drawer = handle.host.querySelector('.clipart-drawer') as HTMLElement
expect(drawer).toBeTruthy()
const overflowX = getComputedStyle(drawer).overflowX
const overflowY = getComputedStyle(drawer).overflowY
// `overflow: hidden` applies to both axes. We check each because
// browsers normalise the shorthand into the long-hand properties.
expect(overflowX).toBe('hidden')
expect(overflowY).toBe('hidden')
handle.unmount()
})
})
suite('polygon — vertex bezier actions', () => {
function makeSquare() {
const s = makeFreshStore()
@@ -3777,6 +4156,88 @@ export function registerAllTests(): void {
})
})
suite('button asset — keyframes snap to state times', () => {
// Regression: keyframes inside a button-asset timeline must only
// exist at the 4 state times (BUTTON_STATE_TIMES = normal=1,
// hover=2, pressed=3, disabled=4). Any code path that places or
// moves a keyframe — upsertKeyframe, setKeyframeTime,
// duplicateKeyframe — snaps the time onto the nearest state. Free
// placement outside the 4 slots is disallowed.
function setupButton() {
const s = makeFreshStore()
const btn = s.addButtonAsset('B', 200, 80)
s.enterButtonAssetEdit(btn.id)
const a = s.addAsset({ kind: 'rect', name: 'R', width: 200, height: 80, fill: '#fff' })
const l = s.addLayer(a.id)!
// addLayer already put a keyframe at BUTTON_STATE_TIMES.normal (=1).
return { s, l }
}
test('upsertKeyframe snaps the requested time to the nearest state', () => {
const { s, l } = setupButton()
// 1.4 is closer to state 1 than to state 2 → should land at 1
// (merging with the existing one, rather than creating a new off-state kf).
s.upsertKeyframe(l.id, 1.4, { x: 10 })
expect(l.keyframes.every((k) => BUTTON_STATE_TIMES.normal === k.time || BUTTON_STATE_TIMES.hover === k.time || BUTTON_STATE_TIMES.pressed === k.time || BUTTON_STATE_TIMES.disabled === k.time)).toBe(true)
// 1.6 is closer to state 2 → new keyframe lands at 2
s.upsertKeyframe(l.id, 1.6, { x: 20 })
expect(l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-6)).toBeTruthy()
})
test('setKeyframeTime snaps drag targets onto state times', () => {
const { s, l } = setupButton()
const kf = l.keyframes[0]
expect(kf.time).toBe(BUTTON_STATE_TIMES.normal)
// Drag "toward" hover but not all the way — 1.8 is closer to 2 → snaps to 2.
s.setKeyframeTime(l.id, kf.id, 1.8)
expect(kf.time).toBe(BUTTON_STATE_TIMES.hover)
// And 2.9 snaps to pressed.
s.setKeyframeTime(l.id, kf.id, 2.9)
expect(kf.time).toBe(BUTTON_STATE_TIMES.pressed)
})
test('setKeyframeTime refuses to land on an already-occupied state', () => {
const { s, l } = setupButton()
// Existing kf at state 1. Create a second at state 2.
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.hover, { x: 99 })
const kfAtHover = l.keyframes.find((k) => Math.abs(k.time - 2) < 1e-6)!
// Drag the hover kf toward normal. State 1 is already occupied — refuse.
s.setKeyframeTime(l.id, kfAtHover.id, 1.1)
expect(kfAtHover.time).toBe(BUTTON_STATE_TIMES.hover)
})
test('duplicateKeyframe picks the nearest free state slot', () => {
const { s, l } = setupButton()
// Only state 1 is occupied; duplicate should land on the next
// closest free slot — state 2 (hover).
const sourceId = l.keyframes[0].id
const newId = s.duplicateKeyframe(l.id, sourceId)!
expect(newId).toBeTruthy()
const dup = l.keyframes.find((k) => k.id === newId)!
expect(dup.time).toBe(BUTTON_STATE_TIMES.hover)
})
test('duplicateKeyframe returns null when every state slot is taken', () => {
const { s, l } = setupButton()
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.hover, { x: 2 })
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.pressed, { x: 3 })
s.upsertKeyframe(l.id, BUTTON_STATE_TIMES.disabled, { x: 4 })
const sourceId = l.keyframes.find((k) => Math.abs(k.time - 1) < 1e-6)!.id
const newId = s.duplicateKeyframe(l.id, sourceId)
expect(newId).toBe(null)
})
test('on the main timeline, snapping is NOT applied (free placement)', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1.4, { x: 5 })
// No button-edit context → the kf lives at the exact requested time.
expect(l.keyframes.some((k) => Math.abs(k.time - 1.4) < 1e-6)).toBe(true)
})
})
suite('button asset — mask rendering', () => {
// Regression: masks inside buttons used to be ignored by every render
// path — the button branch in drawLayers called drawLayers recursively