macro-clips slice 7: button state transitions

- ButtonClip transitions are now editable in ButtonInstanceDialog
  (from/to/duration/easing) and persisted on the clip via the new
  store.setButtonClipTransitions action; transitions are shared by
  every instance of the button (clip-scoped, not instance-scoped).
- lerpProps exported from interpolate.ts so callers outside the
  sampler can blend two poses.
- useButtonRuntime tracks an active transition per instance plus a
  reactive transitionTick that pokes Vue computeds each frame.
  A self-driving RAF loop runs only while some transition is active
  (idles when none), so animations work paused or playing.
- applyButtonStateChange(id, to, transitions, persistedDisabled)
  is the one entry point for state changes — it looks up a matching
  from→to transition and starts it, or flips state instantly. Pointer
  handlers in CanvasStage and store.actionRuntime.setButtonState both
  go through it.
- CanvasStage blends the two state stacks via sampleStateLayers +
  blendStates: layers matched by name interpolate their sampled props
  with lerpProps; unmatched layers fade in/out. Renderer uses the
  blended layer list in place of the pure state stack during transit.
- Export runtime mirrors the same flow: applyButtonChange starts
  transitions, sampleButtonTransition drives per-frame progress, and
  drawButtonStatesBlended composites via drawLayer(layer, 0, override).
  drawLayer gained an optional propsOverride param for this.
  RUNTIME_VERSION bumped to 6.
- Tests cover applyButtonStateChange matching logic and
  sampleTransition's time → u mapping plus done flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-20 21:48:50 +02:00
1 parent f61f90d613
commit 68ea8576b6
8 files changed
+515 -32

No files matched your search

+39 -1
View File
@@ -1,6 +1,9 @@
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
import { createBlankScene, useSceneStore } from '@/stores/scene'
import { useButtonRuntime } from '@/composables/useButtonRuntime'
import {
applyButtonStateChange,
useButtonRuntime,
} from '@/composables/useButtonRuntime'
import {
buildMarkerMap,
evalAction,
@@ -3543,6 +3546,41 @@ export function registerAllTests(): void {
expect(rt.getState('b1', false)).toBe('disabled')
})
test('applyButtonStateChange starts a transition only when one matches', () => {
const rt = useButtonRuntime()
rt.reset()
// No transitions defined: state flips instantly, no transition entry.
applyButtonStateChange('b1', 'hover', undefined, undefined)
expect(rt.getState('b1')).toBe('hover')
expect(rt.transition.has('b1')).toBe(false)
// With a matching transition: flip state AND start transition.
rt.reset()
applyButtonStateChange(
'b1',
'hover',
[{ from: 'normal', to: 'hover', duration: 0.2, easing: 'easeOut' }],
undefined,
)
expect(rt.getState('b1')).toBe('hover')
expect(rt.transition.has('b1')).toBe(true)
rt.endTransition('b1')
})
test('sampleTransition returns null outside of transitions, u∈[0..1] within', () => {
const rt = useButtonRuntime()
rt.reset()
expect(rt.sampleTransition('b1', performance.now())).toBe(null)
const t0 = performance.now()
rt.startTransition('b1', 'normal', 'hover', 1, 'linear', t0)
const mid = rt.sampleTransition('b1', t0 + 500)!
expect(mid.done).toBe(false)
expect(mid.u > 0.4 && mid.u < 0.6).toBe(true)
const end = rt.sampleTransition('b1', t0 + 1100)!
expect(end.done).toBe(true)
expect(end.u).toBe(1)
rt.endTransition('b1')
})
test('store.actionRuntime resolves labels and drives useButtonRuntime', () => {
const s = makeFreshStore()
const rt = useButtonRuntime()