0.8.0: group colour cascade, still-frame past end, project rename, UX polish

Headline:

- Group colour cascades to every non-overridden child (layer or clip
  track). New `colorOverridden` flag on Layer / ClipTrack / ScriptTrack
  / TrackGroup; setTrackColor on a non-group pins the track, clearing
  re-inherits from the parent group. moveTrackIntoGroup also inherits.
  normalizeScene migrates legacy scenes (existing colour ⇒ overridden).
- Keyframes inside a button-asset timeline snap to the 4 state slots
  (normal/hover/pressed/disabled). upsertKeyframe / setKeyframeTime /
  duplicateKeyframe all enforce it; colliding drags refuse the move,
  duplicates walk to the nearest free slot.
- Backspace / Delete on a selected layer removes it (respecting the
  layer's own lock and any enclosing group lock).
- "Still frame" allowed on the last keyframe: the layer stays on stage
  past its final kf. Timeline draws a solid dim tail line from the
  last kf to the end, and single-keyframe tracks now show a full-span
  line so on-stage/off-stage reads at a glance. Height stays 3px.
- Project name moved next to the logo, turns into a rename button on
  hover (pencil fades in). Rename syncs the stored record when the
  project is already persisted.
- Shadow + blur keyframes animate in the preview and exported runtime:
  the runtime's lerpProps used to drop them, so every preview rendered
  shadow=0 between keyframes. RUNTIME_VERSION 11 → 13 (covers the
  button-mask + shadow fixes). Held-last-kf also taught to the runtime.
- Button masks render across every path: edit-mode mask-preview,
  nested buttons on the main stage, sidebar thumbnails, fullscreen
  preview + exports. drawButtonFrame new in render.ts.

Smaller fixes:

- Dragging layer A on the stage with a stale selection on layer B used
  to move B and snap A back. Now the shift-selected branch only runs
  when the dragged layer is itself in the selection.
- Clip-track row was missing its icon — PhFilmStrip was referenced but
  never imported. Added, plus dropped the bespoke `gap` on clip / script
  name rows so their icon+label spacing matches layer rows.
- Clip-art drawer pre-lays-out its 400px grid behind `overflow: hidden`
  so slide-in reveals a finished grid instead of reflowing auto-fill
  columns over 220ms.
- Timeline clip-track dropzone hides until the library has a clip; its
  "drop a clip here" hint stays centred in the visible ruler area.
- Keyframe context menu: check-mark and icons share a single 12px column
  so the ✓ on an easing option lines up with PhPauseCircle / PhCopy /
  PhTrash on the rows below. "Still frame"'s active indicator moved to
  the trailing-check slot.
- Button editor's mask-preview now shows the button's own stage instead
  of leaking through to the main scene. drawButtonFrame drives it.

Regression tests cover all of the above: group-color cascade, pin,
clear-re-inherit, migration, button-kf snap and collision rules,
backspace-deletes-selected-layer (incl. lock guards + editable-target
guard), still-frame-past-end visibility, clipart drawer rigid width,
mask rendering in drawButtonFrame / drawSceneFrame, shadow + blur
interpolation in both canvas and runtime paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-21 21:26:06 +02:00
1 parent ac89f6762d
commit 1e89ea86e0
15 files changed
+849 -74

No files matched your search

+79 -33
View File
@@ -8,6 +8,7 @@ import {
PhCode,
PhCopy,
PhDotsSixVertical,
PhFilmStrip,
PhFolder,
PhFolderPlus,
PhLock,
@@ -1062,12 +1063,35 @@ interface KfSegment {
toTime: number
easing: Easing
held: boolean
/** True when this segment is the "still-frame tail" extending past
* the last keyframe to the end of the timeline. Rendered dimmer so
* it reads as an after-image rather than an interpolation. */
tail?: boolean
}
function segmentsOf(kfs: Keyframe[]): KfSegment[] {
if (kfs.length < 2) return []
function segmentsOf(kfs: Keyframe[], duration: number): KfSegment[] {
if (kfs.length === 0) return []
const sorted = [...kfs].sort((a, b) => a.time - b.time)
const out: KfSegment[] = []
// Single keyframe means the layer is always visible (no animation
// range), so the line should span the entire timeline as a solid
// dim "on-stage" indicator — same treatment as a still-frame tail.
if (sorted.length === 1) {
const only = sorted[0]
if (duration > 0) {
out.push({
id: `${only.id}>full`,
fromTime: 0,
toTime: duration,
easing: only.easing,
held: false,
tail: true,
})
}
return out
}
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i]
const b = sorted[i + 1]
@@ -1079,6 +1103,20 @@ function segmentsOf(kfs: Keyframe[]): KfSegment[] {
held: !!a.hold,
})
}
// Last-keyframe "still frame" tail: when the final keyframe is held,
// extend the on-stage indicator to the end of the timeline. Solid
// and dim — the value isn't interpolating, it's just idling.
const last = sorted[sorted.length - 1]
if (last.hold && duration > last.time) {
out.push({
id: `${last.id}>tail`,
fromTime: last.time,
toTime: duration,
easing: last.easing,
held: false,
tail: true,
})
}
return out
}
@@ -1974,6 +2012,20 @@ function onDocumentKeyDown(e: KeyboardEvent) {
store.removeMarker(id)
return
}
// Selected-track deletion. Only layer tracks carry a selection
// today (clip / script / group tracks don't set selectedLayerId).
// Skip the delete when the layer is locked directly or through its
// parent group — Backspace should respect the same locks that block
// mouse interaction.
if (store.selectedLayerId) {
const layerId = store.selectedLayerId
const layer = store.activeLayers.find((l) => l.id === layerId)
if (!layer) return
if (layer.locked || lockedGroupChildIds.value.has(layerId)) return
e.preventDefault()
store.removeLayer(layerId)
return
}
}
if (
e.key.toLowerCase() === 'm' &&
@@ -2390,10 +2442,10 @@ onBeforeUnmount(() => {
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="seg in segmentsOf(track.kfs)"
v-for="seg in segmentsOf(track.kfs, duration)"
:key="seg.id"
class="kf-seg"
:class="[seg.held ? 'held' : `easing-${seg.easing}`]"
:class="[seg.held ? 'held' : `easing-${seg.easing}`, { tail: seg.tail }]"
:style="segmentStyle(seg)"
/>
<span
@@ -2777,14 +2829,13 @@ onBeforeUnmount(() => {
:disabled="menu.isFirst || menu.isHeld"
@click="applyEasing(opt.value)"
>
<span class="check">
<span class="kf-menu-icon">
<PhCheck
v-if="menu.currentEasing === opt.value && !menu.isHeld"
:size="12"
weight="bold"
/>
</span>
<span class="kf-menu-icon-spacer"></span>
{{ opt.label }}
</button>
<div v-if="menu.isFirst" class="kf-menu-note">{{ t('timeline.firstKfHint') }}</div>
@@ -2794,24 +2845,23 @@ onBeforeUnmount(() => {
<button
class="kf-menu-item"
:class="{ active: menu.isHeld }"
:disabled="menu.isLast"
:title="menu.isLast ? t('timeline.lastKfHint') : ''"
@click="toggleHold"
>
<span class="check">
<PhCheck v-if="menu.isHeld" :size="12" weight="bold" />
</span>
<PhPauseCircle :size="12" class="kf-menu-icon" />
{{ t('timeline.stillFrame') }}
<PhCheck
v-if="menu.isHeld"
:size="12"
weight="bold"
class="kf-menu-trailing-check"
/>
</button>
<div class="kf-menu-divider"></div>
<button class="kf-menu-item" @click="duplicateFromMenu">
<span class="check"></span>
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
<button class="kf-menu-item danger" @click="deleteFromMenu">
<span class="check"></span>
<PhTrash :size="12" class="kf-menu-icon" />
{{ t('timeline.delete') }}
</button>
@@ -3803,6 +3853,21 @@ onBeforeUnmount(() => {
opacity: 0.7;
}
/* "Tail" = on-stage indicator where the layer is visible but no
interpolation is happening (still-frame past the last keyframe, or
a single-keyframe layer that's always on stage). Solid line at low
opacity — reads as "layer idling on stage" rather than "thin line".
Overrides any preceding easing-* gradient background so the fill is
always a flat accent colour at the track's tint. */
.kf-seg.tail,
.kf-seg.tail.easing-linear,
.kf-seg.tail.easing-easeIn,
.kf-seg.tail.easing-easeOut,
.kf-seg.tail.easing-easeInOut {
background: var(--track-color, var(--accent));
opacity: 0.35;
}
.kf {
position: absolute;
top: 50%;
@@ -3871,10 +3936,6 @@ onBeforeUnmount(() => {
color: var(--text-dim);
}
.clip-track-name {
gap: 6px;
}
.clip-lane {
position: relative;
}
@@ -3896,10 +3957,6 @@ onBeforeUnmount(() => {
color: var(--text-dim);
}
.script-track-name {
gap: 6px;
}
.script-lane {
position: relative;
}
@@ -4238,18 +4295,7 @@ onBeforeUnmount(() => {
background: var(--danger-soft);
}
.check {
width: 14px;
height: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--accent);
flex-shrink: 0;
}
.kf-menu-icon,
.kf-menu-icon-spacer {
.kf-menu-icon {
width: 12px;
height: 12px;
flex-shrink: 0;