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)" />