From af5f99380c0fd7d9819c8e1b82e714672b159631 Mon Sep 17 00:00:00 2001
From: th
Date: Wed, 22 Apr 2026 04:31:08 +0200
Subject: [PATCH] 0.8.4: button transitions, locked groups revoke edits, UX
polish
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
package.json | 2 +-
src/App.vue | 6 +-
src/components/CanvasStage.vue | 24 +++--
src/components/PropertiesPanel.vue | 18 +++-
src/components/Timeline.vue | 43 +++++++--
src/composables/useButtonRuntime.ts | 138 ++++++++++++++++++++++++++--
src/devtools/tests.ts | 136 +++++++++++++++++++++++++++
src/i18n/messages.ts | 2 +
src/stores/scene.ts | 26 ++++++
src/styles/global.css | 14 +++
src/version.ts | 2 +-
todo | 24 +++--
12 files changed, 401 insertions(+), 34 deletions(-)
diff --git a/package.json b/package.json
index b09b768..4172f8e 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "animatr",
"private": true,
- "version": "0.8.3",
+ "version": "0.8.4",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/App.vue b/src/App.vue
index e594168..bb2f7fb 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -812,7 +812,11 @@ onBeforeUnmount(() => {
right: 0;
height: 14px;
cursor: ns-resize;
- z-index: 20;
+ /* Must sit above everything inside the Timeline — its .scrub-bar is
+ `position: sticky; z-index: 40`, which (because the Timeline root
+ has no z-index of its own) competes in this same stacking context
+ and would otherwise swallow the bottom half of the grab area. */
+ z-index: 100;
touch-action: none;
background: transparent;
transition: background 120ms var(--ease);
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
index a175bfd..3cb9760 100644
--- a/src/components/CanvasStage.vue
+++ b/src/components/CanvasStage.vue
@@ -492,12 +492,11 @@ function buttonChildConfig>(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)"
/>
(() =>
selected.value ? sampleLayer(selected.value, store.playhead) : null,
)
+/** True when the selected layer is locked directly or reached through
+ * a locked parent group. Suppresses the edit UI — the user must
+ * unlock the layer (or the group) before changing properties. */
+const selectedIsLocked = computed(() => {
+ const l = selected.value
+ if (!l) return false
+ if (l.locked) return true
+ for (const g of store.scene.groups ?? []) {
+ if (g.locked && g.childIds.includes(l.id)) return true
+ }
+ return false
+})
+
const hasKeyframeHere = computed(() => {
if (!selected.value) return false
return selected.value.keyframes.some((k) => Math.abs(k.time - store.playhead) < 1e-3)
@@ -375,7 +388,7 @@ function removeKeyframeHere() {