0.8.4: button transitions, locked groups revoke edits, UX polish

Button-state transitions
- State flips on stage-preview buttons (normal / hover / pressed /
  disabled) now ease over 50ms instead of snapping. New
  `transitions` map + rAF ticker in useButtonRuntime drive a
  continuous sample time between BUTTON_STATE_TIMES slots;
  CanvasStage samples each button child at that interpolated time
  so keyframes at the 4 states blend smoothly. Mid-flight flips
  (hover → pressed while still in normal → hover) rebase from
  the current live sample instead of snapping.

Locked groups revoke edits
- Locking a layer directly or its enclosing group deselects the
  layer so the Properties panel can't keep editing it. Panel also
  splits into three branches — editable / locked / no-layer —
  with a dedicated locked-layer hint.
- Timeline keyframe interactions (drag, dblclick-to-remove,
  context menu, dblclick-to-create on lane, add-kf toolbar)
  now consult effective lock (direct + parent group) instead
  of just layer.locked.

Editor polish
- Inactive tracks fade as a whole row (opacity 0.5) instead of
  per-child, so keyframes, segments, and still-frame tails stay
  visually proportional to their active counterparts.
- Dragging a layer onto a group's track-name label now drops
  it INTO the group (was previously only the group-lane area).
- Button assets on the stage show a pointer cursor on hover so
  it's obvious they're interactive.
- Global CSS forces `cursor: pointer !important` on every enabled
  button / role="button" so scoped component styles can no longer
  accidentally shadow the default.
- Timeline resize handle jumped above the scrub-bar (z-index 100)
  so the full 14px grab zone is hit-testable again.

Regression tests
- setTrackLocked deselect paths: direct lock, group lock with
  selected child, group lock with unrelated selection, unlock
  doesn't re-select.
- duplicateKeyframeAt: props + easing + hold + bezier copied,
  collision refusal, unknown-id guards, separated-track routing.
- Button transition runtime: no-transition baseline, mid-flight
  bounds, mid-transition rebase, no-op setState.
- Shared composables tooltips + asset-thumbs: same ref across
  calls, localStorage round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-22 04:31:08 +02:00
1 parent 1d84c34c15
commit af5f99380c
12 files changed
+401 -34

No files matched your search

+136
View File
@@ -3232,6 +3232,59 @@ export function registerAllTests(): void {
// The layer itself stays unlocked; effective lock is derived in UI code.
expect(l.locked).toBe(undefined)
})
test('locking a layer directly deselects it', () => {
// Regression: locking a layer while it was selected used to leave
// the props panel happily editing the now-inert track. The
// selection must drop on lock so edits can't leak through.
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const l = s.addLayer(a.id)!
s.selectLayer(l.id)
expect(s.selectedLayerId).toBe(l.id)
s.setTrackLocked(l.id, true)
expect(s.selectedLayerId).toBe(null)
})
test('locking a group deselects a selected child layer', () => {
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.selectLayer(l.id)
expect(s.selectedLayerId).toBe(l.id)
s.setTrackLocked(g.id, true)
expect(s.selectedLayerId).toBe(null)
})
test('locking a group with no selected child leaves selection alone', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#f00' })
const b = s.addAsset({ kind: 'rect', name: 'B', width: 10, height: 10, fill: '#0f0' })
const la = s.addLayer(a.id)!
const lb = s.addLayer(b.id)!
const g = s.addTrackGroup('G')
// Only move la into the group; lb is selected and stays top-level.
s.moveTrackIntoGroup(la.id, g.id)
s.selectLayer(lb.id)
s.setTrackLocked(g.id, true)
// Locking the group can't touch the selection that lives outside it.
expect(s.selectedLayerId).toBe(lb.id)
})
test('unlocking a group does not auto-reselect the previously-selected layer', () => {
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.selectLayer(l.id)
s.setTrackLocked(g.id, true)
// Now unlocking — selection stays null; user re-selects explicitly.
s.setTrackLocked(g.id, false)
expect(s.selectedLayerId).toBe(null)
})
})
suite('render — crop option', () => {
@@ -4269,6 +4322,89 @@ export function registerAllTests(): void {
})
})
suite('button asset — state transition runtime', () => {
// Regression: button state changes used to snap instantly between
// the normal / hover / pressed / disabled slots. `setState` now
// records a transition whose sample time lerps between the
// corresponding `BUTTON_STATE_TIMES` slots over
// `BUTTON_STATE_TRANSITION_MS`. `getEffectiveTime` is what the
// renderer reads; it must return an intermediate time during the
// transition and snap to the target once finished.
async function importRt() {
const mod = await import('@/composables/useButtonRuntime')
return {
rt: mod.useButtonRuntime(),
DURATION: mod.BUTTON_STATE_TRANSITION_MS,
}
}
test('getEffectiveTime returns the target slot when no transition is running', async () => {
const { rt } = await importRt()
rt.clear('btn-a')
expect(rt.getEffectiveTime('btn-a', false)).toBe(BUTTON_STATE_TIMES.normal)
rt.clear('btn-a')
// Fast-path: disabled layers report disabled regardless of the
// runtime state or transitions.
expect(rt.getEffectiveTime('btn-a', true)).toBe(BUTTON_STATE_TIMES.disabled)
rt.clear('btn-a')
})
test('setState kicks off a transition; mid-flight time lies between slots', async () => {
const { rt, DURATION } = await importRt()
rt.clear('btn-b')
rt.setState('btn-b', 'hover')
// Immediately after setState, time is ~fromTime (normal=1) and
// approaching toTime (hover=2).
const t0 = rt.getEffectiveTime('btn-b', false)
expect(t0).toBeGreaterThan(BUTTON_STATE_TIMES.normal - 0.001)
expect(t0).toBeLessThan(BUTTON_STATE_TIMES.hover)
// After half the duration the time should be around halfway.
await new Promise((r) => setTimeout(r, DURATION / 2))
const tMid = rt.getEffectiveTime('btn-b', false)
expect(tMid).toBeGreaterThan(BUTTON_STATE_TIMES.normal)
expect(tMid).toBeLessThan(BUTTON_STATE_TIMES.hover)
// After the full duration (+ slack) the transition resolves to
// the target slot exactly.
await new Promise((r) => setTimeout(r, DURATION + 40))
expect(rt.getEffectiveTime('btn-b', false)).toBe(BUTTON_STATE_TIMES.hover)
rt.clear('btn-b')
})
test('mid-transition setState rebases from the live sample time', async () => {
// Regression: if hover → pressed flips in-flight from a previous
// normal → hover transition, the new transition should start at
// the *current* intermediate sample time instead of snapping
// back to the old `from` slot. Otherwise a quick hover+press
// produces a visible jump.
const { rt, DURATION } = await importRt()
rt.clear('btn-c')
rt.setState('btn-c', 'hover')
await new Promise((r) => setTimeout(r, DURATION / 2))
// At this point the effective time is roughly halfway between
// normal (1) and hover (2) — somewhere around 1.4–1.6.
const midway = rt.getEffectiveTime('btn-c', false)
rt.setState('btn-c', 'pressed')
// The freshly-started transition must begin at or just past the
// midway sample, not drop back toward normal (1).
const after = rt.getEffectiveTime('btn-c', false)
// Allow a small drift forward because `setTimeout` and the
// subsequent assignment both burn a few ms — the important
// thing is we didn't regress into the [1, midway) interval.
expect(after).toBeGreaterThan(midway - 0.01)
rt.clear('btn-c')
})
test('no-op setState (target === current) does not start a transition', async () => {
const { rt } = await importRt()
rt.clear('btn-d')
// Currently normal; asking for normal again is a no-op.
rt.setState('btn-d', 'normal')
expect(rt.getEffectiveTime('btn-d', false)).toBe(BUTTON_STATE_TIMES.normal)
rt.clear('btn-d')
})
})
suite('button asset — thumbnail rendering', () => {
// Regression: renderButtonThumbnail used to pass images={} and any image
// layer inside a button produced a fully-transparent PNG, making