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

+33 -10
View File
@@ -544,14 +544,16 @@ const canCreateKf = computed(() => {
const id = store.selectedLayerId
if (!id) return false
const layer = store.activeLayers.find((l) => l.id === id)
return !!layer && !layer.locked
if (!layer) return false
if (layer.locked) return false
return !lockedGroupChildIds.value.has(id)
})
function addKeyframeForSelected() {
const id = store.selectedLayerId
if (!id) return
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer || layer.locked) return
if (!layer || isLayerIdLocked(id)) return
const time = snapTime(store.playhead)
const sampled = sampleLayer(layer, time)
const props: Partial<AnimatableProps> = {
@@ -576,7 +578,7 @@ function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const time = snapTime(ratio * duration.value)
const layer = store.activeLayers.find((l) => l.id === track.layerId)
if (!layer || layer.locked) return
if (!layer || isLayerIdLocked(track.layerId)) return
const sampled = sampleLayer(layer, time)
const props: Partial<AnimatableProps> = {}
if (track.kind === 'position') {
@@ -617,8 +619,7 @@ function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string) {
e.stopPropagation()
if (kfDrag.value?.moved) return
const layer = store.activeLayers.find((l) => l.id === layerId)
if (layer?.locked) return
if (isLayerIdLocked(layerId)) return
store.removeKeyframe(layerId, keyframeId)
}
@@ -665,6 +666,17 @@ const lockedGroupChildIds = computed(() => {
return s
})
/** Effective lock for a bare layer id — direct flag OR reached by a
* locked parent group. Keyframe interactions consult this so edits
* are blocked the moment the containing group is locked, without
* having to look up the TimelineTrack wrapper. */
function isLayerIdLocked(layerId: string): boolean {
const layer = store.activeLayers.find((l) => l.id === layerId)
if (!layer) return false
if (layer.locked) return true
return lockedGroupChildIds.value.has(layerId)
}
function isTrackEffectivelyLocked(track: TimelineTrack): boolean {
if (track.type === 'layer') {
return !!track.layer.locked || lockedGroupChildIds.value.has(track.layerId)
@@ -901,7 +913,7 @@ function onKeyframePointerDown(
) {
if (e.button !== 0) return
const layer = store.activeLayers.find((l) => l.id === layerId)
if (layer?.locked) return
if (isLayerIdLocked(layerId)) return
const kfEl = e.currentTarget as HTMLElement
const lane = kfEl.parentElement
if (!lane) return
@@ -1193,6 +1205,7 @@ function onKeyframeContextMenu(
) {
e.preventDefault()
e.stopPropagation()
if (isLayerIdLocked(layerId)) return
const layer = store.activeLayers.find((l) => l.id === layerId)
const kf = kfs.find((k) => k.id === keyframeId)
if (!layer || !kf) return
@@ -2754,12 +2767,16 @@ onBeforeUnmount(() => {
>
<span
class="track-name group-track-name"
:class="{ 'drop-into': dropGroupId === track.groupId }"
:draggable="!track.locked"
@click="toggleGroupCollapsed(track.groupId)"
@dblclick="!track.locked ? renameGroupPrompt(track.groupId, track.name) : null"
@contextmenu="!track.locked ? onGroupContextMenu($event, track.groupId) : null"
@dragstart="!track.locked ? onTrackDragStart($event, track.groupId) : null"
@dragend="onTrackDragEnd"
@dragover.stop="!track.locked ? onGroupBodyDragOver($event, track.groupId) : null"
@drop.stop="!track.locked ? onGroupBodyDrop($event, track.groupId) : null"
@dragleave="onGroupBodyDragLeave"
>
<PhDotsSixVertical :size="12" class="drag-handle" />
<component
@@ -3606,13 +3623,19 @@ onBeforeUnmount(() => {
bottom: -1px;
}
.track.inactive .track-lane > :not(.playhead) {
opacity: 0.4;
/* Inactive tracks fade as a whole row — same visual vocabulary as
the active track, just dimmer. Applying opacity to the row itself
keeps keyframes, segments, and the still-frame tail visually in
sync (previously we faded each child individually, which multiplied
against each child's own opacity and made diamonds read as ghostly
while their connecting segments looked nearly normal). */
.track.inactive {
opacity: 0.5;
transition: opacity 140ms var(--ease);
}
.track.inactive:hover .track-lane > :not(.playhead) {
opacity: 0.75;
.track.inactive:hover {
opacity: 0.85;
}
.track.locked {