Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
85b596d
feat(menus): add loop-region playback and frame-stepping to the playb…
ScottMorris Aug 19, 2026
eec7ff3
feat(menus): add timeline geometry, keyframe writers, and preview-sam…
ScottMorris Aug 19, 2026
aea5476
feat(menus): build the timeline strip UI components
ScottMorris Aug 19, 2026
fdc9334
feat(menus): mount the timeline strip below the canvas and wire its w…
ScottMorris Aug 19, 2026
545d7e3
feat(menus): replace the Static/Animated dropdown with a highlight-an…
ScottMorris Aug 19, 2026
5174baf
feat(menus): remove the scrub slider now superseded by the timeline s…
ScottMorris Aug 19, 2026
0dcc850
feat(menus): animate the navigation preview's highlight from animatio…
ScottMorris Aug 19, 2026
c9e3d96
test(menus): cover the timeline strip and animated preview highlight
ScottMorris Aug 19, 2026
83d0a18
fix(menus): label activate-colour/opacity tracks on the timeline strip
ScottMorris Aug 19, 2026
d1040a8
fix(menus): stop identity-write timeline drags and add frame-accurate…
ScottMorris Aug 19, 2026
299b5ec
fix(menus): resolve play() from its promise and stop the native <vide…
ScottMorris Aug 19, 2026
fae0349
fix(menus): disable "Add keyframe at playhead" on still menus
ScottMorris Aug 19, 2026
4e6201f
fix(menus): match the navigation preview's honest sampling to the rea…
ScottMorris Aug 19, 2026
f34b142
style(menus): run prettier over the PR8 fix commits
ScottMorris Aug 19, 2026
d089738
fix(menus): sample the existing highlight track when adding a keyfram…
ScottMorris Aug 19, 2026
c569468
fix(menus): keep the keyframe popover bound to identity across a retime
ScottMorris Aug 19, 2026
d5460f4
docs(menus): shorten TimelineKeyframeLane's file header
ScottMorris Aug 19, 2026
31e4d4c
test(menus): cover popover identity across keyframe reorders
ScottMorris Aug 19, 2026
17ab6f3
fix(menus): pin the scrubber track to the ruler's own pixel scale
ScottMorris Aug 19, 2026
a0959b1
fix(menus): stop the keyframe-lane label gutter shifting timeline coo…
ScottMorris Aug 19, 2026
11e404a
feat(menus): add sampleHonestFold for menu-wide honest-preview sampling
ScottMorris Aug 19, 2026
78e97bf
fix(menus): match honest preview's schedule union and CLUT fold to th…
ScottMorris Aug 19, 2026
96b6ff4
style(menus): run prettier over the add-at-playhead sampling fix
ScottMorris Aug 19, 2026
0aa332f
fix(menus): clamp the keyframe popover's typed timestamp to the loop …
ScottMorris Aug 19, 2026
b1416ae
fix(menus): drop a deleted node's animation tracks with it
ScottMorris Aug 19, 2026
766f839
docs(menus): move TimelineScrubber's header narrative below the licen…
ScottMorris Aug 19, 2026
96eeeb5
fix(menus): clamp region-bar edge drags to the known source duration
ScottMorris Aug 19, 2026
08dac28
fix(menus): stop the transport controls shifting the scrubber's origin
ScottMorris Aug 19, 2026
2a1aa22
fix(menus): raise keyframe diamonds above the track-label overlay
ScottMorris Aug 19, 2026
0b96eb4
fix(menus): match honest preview's terminal clamp and button set to t…
ScottMorris Aug 19, 2026
2b2fd32
fix(menus): fall back to a blob URL when asset:// video streaming fails
ScottMorris Aug 19, 2026
e69d2ca
fix(menus): stop the keyframe popover from editing the wrong keyframe
ScottMorris Aug 19, 2026
a28dedf
docs(menus): shorten timeline source headers to a one-sentence summary
ScottMorris Aug 19, 2026
2abe70a
fix(menus): fold still-menu tracks in honest preview to match the disc
ScottMorris Aug 19, 2026
e959f99
fix(menus): anchor the keyframe popover within the visible timeline
ScottMorris Aug 19, 2026
15c1f80
fix(menus): keep the scrubber's early seconds interactive
ScottMorris Aug 19, 2026
100d26b
fix(build): round the highlight outline like the scene renderer's but…
ScottMorris Aug 19, 2026
ad9dba7
fix(menus): keep loop drags and keyboard deletes from stranding keyfr…
ScottMorris Aug 19, 2026
8d56f5f
fix(menus): scale preview text with the canvas; wrap ended loops; cle…
ScottMorris Aug 19, 2026
1f2b0b9
fix(menus): abort the blob-fallback fetch on unmount and asset switch
ScottMorris Aug 19, 2026
64a5bd1
docs(menus): shorten animationWriters.ts header to a one-sentence sum…
ScottMorris Aug 19, 2026
6700c92
fix(menus): preserve a keyframe's timestamp while its popover field i…
ScottMorris Aug 19, 2026
6fcb570
fix(menus): suppress the trailing click's selection after a reorderin…
ScottMorris Aug 19, 2026
693d43f
fix(menus): sample activate-colour/opacity defaults for empty tracks
ScottMorris Aug 19, 2026
e2bbc14
fix(menus): remove tracks for a deleted group's whole subtree
ScottMorris Aug 19, 2026
7c3d321
fix(menus): clamp typed opacity keyframes to the 0..1 range
ScottMorris Aug 19, 2026
9671169
fix(menus): normalise duplicate animation tracks at load, last-listed…
ScottMorris Aug 19, 2026
b2fd714
fix(menus): clear stale keyframe indices after undo; enforce the blob…
ScottMorris Aug 19, 2026
70a082a
fix(menus): re-sync popover drafts to the persisted value on blur
ScottMorris Aug 19, 2026
f5c155c
refactor(menus): bind lane selection and popover by keyframe identity…
ScottMorris Aug 19, 2026
9047ff0
fix(menus): make duplicate keyframe timestamps unrepresentable
ScottMorris Aug 19, 2026
4a6fa01
fix(menus): commit popover retimes on blur/Enter, never per keystroke
ScottMorris Aug 19, 2026
c7e10e9
fix(menus): skip identity retimes; guard stale play settles; honest f…
ScottMorris Aug 19, 2026
992b439
fix(menus): stop a keyframe deletion keystroke from also deleting the…
ScottMorris Aug 19, 2026
4609476
fix(menus): floor typed durations at the latest keyframe; idle hidden…
ScottMorris Aug 19, 2026
f3a2f10
fix(menus): Canadian quantise spellings; reset interrupted timeline d…
ScottMorris Aug 19, 2026
5740999
fix(menus): draft-commit the duration input; skip unchanged region co…
ScottMorris Aug 19, 2026
e59bc02
fix(menus): skip identity duration commits
ScottMorris Aug 19, 2026
c0f75b7
fix(menus): rebind the scrubber scroll sync when the strip becomes vi…
ScottMorris Aug 19, 2026
f49bfb6
fix(menus): treat floor-clamped duration entries as identity commits
ScottMorris Aug 19, 2026
7702642
fix(menus): align the still degrade with the evaluator's duplicate ru…
ScottMorris Aug 20, 2026
7e5661a
fix(menus): gate the playhead loop on motion, not just visibility
ScottMorris Aug 20, 2026
0aadfa2
fix(menus): gate the playhead loop on a registered video
ScottMorris Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 40 additions & 14 deletions apps/spindle/src/components/menus/ButtonInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
MenuHighlightColours,
Title,
Menu,
MenuDocument,
MenuDomain,
TextStyle,
FontEntry,
Expand Down Expand Up @@ -41,6 +42,8 @@ export function ButtonInspector({
buttonPreviewState,
onButtonPreviewStateChange,
availableFonts,
document,
onAddKeyframeAtPlayhead,
}: {
button: MenuButton;
buttonNode: Extract<SceneNode, { type: 'button' }>;
Expand All @@ -59,7 +62,13 @@ export function ButtonInspector({
buttonPreviewState: ButtonVisualState;
onButtonPreviewStateChange?: (state: ButtonVisualState) => void;
availableFonts?: FontEntry[];
/** Authored document — used to summarise this button's animation tracks. */
document?: MenuDocument | null;
/** Add a highlight-colour keyframe at the current preview playhead. */
onAddKeyframeAtPlayhead?: (buttonId: string) => void;
}) {
const animationTracks = (document?.animation ?? []).filter((t) => t.nodeId === button.id);
const keyframeCount = animationTracks.reduce((sum, t) => sum + t.keyframes.length, 0);
const isDefault = defaultFocusId === button.id;

return (
Expand Down Expand Up @@ -248,20 +257,37 @@ export function ButtonInspector({
);
})()}

{/* Highlight */}
<CollapsibleSection title="Highlight Mode" defaultOpen>
<select
className="inspector-panel__select"
value={button.highlightMode}
onChange={(e) =>
onUpdateButton(button.id, {
highlightMode: e.target.value as 'static' | 'animated',
})
}
>
<option value="static">Static</option>
<option value="animated">Animated</option>
</select>
{/* Highlight animation — driven by `document.animation` tracks; edited
on the timeline strip below the canvas, not here. */}
<CollapsibleSection title="Highlight Animation" defaultOpen>
<p className="inspector-panel__hint text-muted">
{keyframeCount === 0
? 'No animated highlight yet. Add a keyframe to start one, then drag and edit it on the timeline.'
: `${keyframeCount} keyframe${keyframeCount === 1 ? '' : 's'} across ${animationTracks.length} track${
animationTracks.length === 1 ? '' : 's'
} — edit on the timeline below the canvas.`}
</p>
{onAddKeyframeAtPlayhead &&
(() => {
const isMotion = document?.backgroundMode === 'motion';
return (
<div className="inspector-panel__actions-row">
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => onAddKeyframeAtPlayhead(button.id)}
disabled={!isMotion}
title={
isMotion
? 'Add a highlight-colour keyframe at the current preview playhead'
: 'Keyframe animation requires a motion background — set the background mode to Motion first'
}
>
Add keyframe at playhead
</button>
</div>
);
})()}
</CollapsibleSection>

{/* Overlay Colours */}
Expand Down
7 changes: 7 additions & 0 deletions apps/spindle/src/components/menus/InspectorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ export interface InspectorPanelProps {
formatProfile?: FormatProfile;
/** Reassign the current menu's semantic role. */
onUpdateRole?: (role: MenuRole) => void;
/** Add a highlight-colour keyframe for a button at the current preview
* playhead position — the "Add keyframe at playhead" affordance in
* `ButtonInspector`'s Highlight Animation row. */
onAddKeyframeAtPlayhead?: (buttonId: string) => void;
}

export function InspectorPanel({
Expand Down Expand Up @@ -156,6 +160,7 @@ export function InspectorPanel({
availableFonts,
formatProfile,
onUpdateRole,
onAddKeyframeAtPlayhead,
}: InspectorPanelProps) {
const inspectorTitle = getInspectorTitle(selectedNode, selectedButton);
const inspectorSubtitle = getInspectorSubtitle(selectedNode, selectedButton, buttons);
Expand Down Expand Up @@ -241,6 +246,8 @@ export function InspectorPanel({
buttonPreviewState={buttonPreviewState ?? 'normal'}
onButtonPreviewStateChange={onButtonPreviewStateChange}
availableFonts={availableFonts}
document={document ?? null}
onAddKeyframeAtPlayhead={onAddKeyframeAtPlayhead}
/>
) : selectedNode.type === 'text' ? (
<TextNodeInspector
Expand Down
51 changes: 51 additions & 0 deletions apps/spindle/src/components/menus/MenuEditor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Tests for `collectSubtreeNodeIds`, the helper that finds every id in a
// scene node's own subtree (itself plus, for a `group`, every descendant).
//
// (c) Copyright 2026 Liminal HQ, Scott Morris
// SPDX-License-Identifier: MIT

import { describe, expect, it } from 'vitest';
import type { SceneNode } from '../../types/project';
import { collectSubtreeNodeIds } from './MenuEditor';

describe('collectSubtreeNodeIds', () => {
it('returns just its own id for a leaf node', () => {
const shape: SceneNode = { type: 'shape', id: 'shape-1', x: 0, y: 0, width: 10, height: 10 };
expect(collectSubtreeNodeIds(shape)).toEqual(['shape-1']);
});

it('collects a group and all of its direct children', () => {
const group: SceneNode = {
type: 'group',
id: 'group-1',
name: 'Group',
children: [
{ type: 'shape', id: 'child-1', x: 0, y: 0, width: 10, height: 10 },
{ type: 'text', id: 'child-2', content: 'hi', x: 0, y: 0, width: 10, height: 10 },
],
};
expect(collectSubtreeNodeIds(group)).toEqual(['group-1', 'child-1', 'child-2']);
});

it('recurses into nested groups', () => {
// Regression test: deleting a top-level group removes its whole child
// subtree from `scene.nodes`, so the animation-track cleanup must walk
// the same subtree — not just the group's own id — or a deeply nested
// child's track is orphaned and trips `menu.animation-node-missing`.
const nested: SceneNode = {
type: 'group',
id: 'outer',
name: 'Outer',
children: [
{
type: 'group',
id: 'inner',
name: 'Inner',
children: [{ type: 'shape', id: 'leaf', x: 0, y: 0, width: 10, height: 10 }],
},
{ type: 'shape', id: 'sibling', x: 0, y: 0, width: 10, height: 10 },
],
};
expect(collectSubtreeNodeIds(nested)).toEqual(['outer', 'inner', 'leaf', 'sibling']);
});
});
Loading
Loading