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

+17 -7
View File
@@ -492,12 +492,11 @@ function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
return { ...cfg, draggable: false, listening: false }
}
function buttonStackForState(
function buttonStackForTime(
asset: ButtonAsset,
state: ButtonState,
stateTime: number,
): Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> {
const out: Array<{ layer: Layer; asset: Asset; props: AnimatableProps }> = []
const stateTime = BUTTON_STATE_TIMES[state]
const layers = asset.layers.slice().reverse()
for (const l of layers) {
if (!l.visible) continue
@@ -508,13 +507,24 @@ function buttonStackForState(
return out
}
function setStageCursor(cursor: string) {
const el = stageRef.value?.getStage().container()
if (el) el.style.cursor = cursor
}
function onButtonEnter(layer: Layer) {
// Pointer cursor whenever a button asset is under the mouse — it's
// interactive UX even in edit mode, where it's draggable like any
// other layer. In preview mode, also flip the state machine into
// `hover` so visual state changes fire.
if (!isLayerEffectivelyLocked(layer)) setStageCursor('pointer')
if (!stagePreview.value) return
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
if (cur === 'normal') buttonRt.setState(layer.id, 'hover')
}
function onButtonLeave(layer: Layer) {
setStageCursor('')
if (!stagePreview.value) return
const cur = buttonRt.getEffective(layer.id, layer.buttonDisabled)
if (cur === 'hover' || cur === 'pressed') buttonRt.setState(layer.id, 'normal')
@@ -543,12 +553,12 @@ function buttonAssetFor(asset: Asset): ButtonAsset | null {
return asset.kind === 'button' ? asset : null
}
function buttonStateFor(layer: Layer): ButtonState {
function buttonTimeFor(layer: Layer): number {
// In edit mode the button is a static asset — always show its normal
// state regardless of leftover runtime state (e.g. from a previous
// preview session).
if (!stagePreview.value) return 'normal'
return buttonRt.getEffective(layer.id, layer.buttonDisabled)
if (!stagePreview.value) return BUTTON_STATE_TIMES.normal
return buttonRt.getEffectiveTime(layer.id, layer.buttonDisabled)
}
const stageConfig = computed(() => ({
@@ -2912,7 +2922,7 @@ onBeforeUnmount(() => {
:config="buttonHitCatcherConfig(buttonAssetFor(entry.item.asset)!, entry.item.layer)"
/>
<template
v-for="(sub, bli) in buttonStackForState(buttonAssetFor(entry.item.asset)!, buttonStateFor(entry.item.layer))"
v-for="(sub, bli) in buttonStackForTime(buttonAssetFor(entry.item.asset)!, buttonTimeFor(entry.item.layer))"
:key="`${entry.item.layer.id}:${sub.layer.id}:${bli}`"
>
<v-rect