Per-layer modifier that adds noise to the sampled position and
rotation each frame. Pairs thematically with kaleido: both stack
on top of the keyframe sample, both per-layer, both deterministic
so editor and exported runtime render identically.
Math (src/lib/wiggle.ts):
- hash32 + layerSeeds(layerId): three uncorrelated 32-bit seeds per
layer, derived from the id so the same layer always wobbles the
same way.
- wiggleNoise(seed, t): sum-of-three-sines in [-1, 1]. Smooth (C∞),
cheap, no noise-lib dependency. Phase constants are irrational
ratios picked so the streams don't collapse into a low-period
pattern.
- applyWiggle(props, layer, time): adds amp * noise(seed, t*freq)
to x / y / rotation. Defaults: 8px position, 2° rotation, 2Hz.
Wire-in:
- src/lib/interpolate.ts sampleLayer: post-processes the keyframe
sample with applyWiggle. Every renderer goes through sampleLayer
(canvas2d, Konva, button-state, clip-internal), so the modifier
lights up everywhere from a single hook.
- src/lib/exportPlayer.ts engine: byte-for-byte JS mirror of
hash32 / layerSeeds / wiggleNoise / applyWiggle, plus the same
post-process branch in the engine's sample(). RUNTIME_VERSION
31 → 32.
Data + store:
- Layer.wiggle?: { amplitude?, rotation?, frequency? }. All sub-
fields optional with the documented defaults.
- store.setLayerWiggle(id, patch | null): patch object updates
individual fields with magnitude clamping (≥ 0); null deletes
the whole wiggle field.
UI (PropertiesPanel + i18n):
- New "Wiggle" section between Mask and Kaleidoscope. Toggle
(PhWaveSine icon) seeds the pleasant defaults; three RangeSliders
for position amplitude (0-64 px), rotation amplitude (0-45°),
and frequency (0-10 Hz). Hidden for button-asset layers
(consistent with Kaleido's hit-area constraint).
Tests (8 new under "wiggle — procedural shake modifier"):
- wiggleNoise determinism + [-1, 1] bounds across 4 seeds × ~100
time points.
- layerSeeds stability + the three seeds are pairwise distinct;
different ids yield different triples.
- applyWiggle on a layer without `wiggle` is a no-op (returns the
input reference unchanged).
- sampleLayer with wiggle: drift visible across a 1s window and
always within the configured amplitude bands.
- sampleLayer with wiggle is deterministic — same time, same drift.
- setLayerWiggle roundtrip + magnitude clamp + null-delete.
- PropertiesPanel Wiggle toggle writes the wiggle field with the
documented defaults.
- Exported runtime carries wiggleNoise / applyWiggle / wiggleSeeds
and bumps RUNTIME_VERSION to '32'.
Plan for the next two backlog items (motion-path overlay + canvas
eyedropper) is in claude.txt for later.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This release bundles three independently-useful pieces of work that
overlap heavily in shared files (types, store, i18n, App, CanvasStage,
tests), so they ship together.
SVG asset editor
- New asset kind 'svg' (SvgAsset { canvas, layers }), authored from
inside the existing shape primitives (rect / circle / text / polygon /
freeform). "+" button under the Images sidebar header; opens a
sub-editor that swaps the library sidebar for a dedicated layer
panel, hides the timeline, and uses the same canvas-resize flow
("Edit canvas size" via right-click → no Document settings entry
while in scope). Children carry a single t=0 keyframe each.
- Multi-select rotation in SVG scope only: a temporary group pivot
appears at the selection centroid (draggable), rotation around it
shifts each layer's x/y/rotation. Pivot follows the centroid drift
on Konva-transformer scale/move. Outside SVG, transformer
rotateEnabled stays off (group-rotate write-back is unreliable
per prior feedback).
- Authored SVGs are placed as layers in the main scene and rendered
via drawMaskedGroup at t=0; thumbnails surface in the sidebar via
renderSvgThumbnail. Drop / click / drag / dbl-click-to-edit work
via a transparent svg-hit catcher rect routed in the stage handler.
- Freeform default stroke is red (#e23b3b) so the open path is
visible immediately. addDrawingPoint resolves the layer through
activeLayers so freeform draws correctly inside SVG and clip
scopes too.
End keyframe type
- Keyframe.kind: 'normal' | 'end'. End kfs carry no props, mark the
visibility cap. layerActiveRange / isLayerActiveAt / sampleKeyframeSet
ignore them for interpolation and use them only as the visible-from
/ visible-until bounds.
- One-time migration in normalizeProject synthesizes an end kf at the
time of the last normal kf for layers that relied on the legacy
"last kf = disappears" semantics; layers using hold:true to extend
visibility past the end skip the migration. Idempotent.
- Timeline UI: new "+end" toolbar button (multi-select fan-out, toggle),
"Convert to end" / "Back to normal" entries in the kf context menu
(easing / still-frame / duplicate hidden for end kfs), end kfs
render as a flat axis-aligned square (vs. the diamond-rotated normal
kf). Render filter drops end kfs that collide with a normal kf at
the same time so legacy migrated projects don't read as four-point
stars.
UX polish
- TAB-collapse stage anchoring: the canvas content stays put when the
asset / properties asides toggle (CanvasStage's ResizeObserver
compensates viewX/Y by the container's bounding-rect drift).
- Topbar clipboard button copies the current frame to the system
clipboard as a PNG via navigator.clipboard.write([ClipboardItem]).
- chromeClick predicate adds .timeline to the editing-panels bail so
the synthetic click after a Timeline box-select drag no longer
wipes the just-committed kf selection.
- Right-click on a polygon vertex stops propagation so the vertex
context menu wins instead of the generic stage menu opening on top.
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>
Button assets
- Re-introduce button-asset type with 4 content states (normal/hover/
pressed/disabled) stored as keyframes at BUTTON_STATE_TIMES, plus a
separate hit-area editor mode.
- useButtonRuntime composable for per-instance state/disabled flags on
the main stage.
- Sidebar library lists button assets with live 80×80 thumbnails;
renderButtonThumbnail now accepts an images map so image-kind layers
inside a button render instead of producing a fully-transparent PNG.
- Compact (no-thumbnail) mode aligns button rows to the library-row
height.
Scope footer
- Button-edit mode now uses the same sidebar scope-footer pattern as
clip-edit (back button to "Main timeline" + active scope chip);
removed the inline exit button from the timeline scrub-bar.
Timeline
- Track-name column default bumped to 260px to match the assets sidebar
above it (storage key bumped to :v2 so stale values reset).
- scrub-bar z-index raised above sticky track-name columns so rows
scroll under it instead of over.
- scrub-handle flips z-index based on horizontal scroll: above
name-spacer when the playhead is in view (no half-clip at t=0),
below when the playhead has scrolled off the left edge (so the
toggle row cleanly covers it).
- name-spacer extends ±3/1px to fully cover the obscured handle.
- scrub-handle head pokes 3px above the ruler with rounded top corners
via clip-path path().
Track context menu
- Added missing i18n keys (renameScriptTrack / deleteScriptTrack /
renameScriptTrackDialog.{title,confirm}).
- Rename items show a visible pencil icon; empty .check slot removed
so items line up flush-left; toggle items place their active ✓ on
the right (kf-menu-trailing-check).
Dev HUD
- Test progress bar flips to --danger as soon as the first failure is
recorded, instead of only at the end of the run.
Regression tests
- renderButtonThumbnail: null on empty button, renders shape layers,
accepts custom images map.
- i18n: renameScriptTrack / deleteScriptTrack / dialog keys resolve.
- scrub-handle-obscured logic (visible at t=0, hidden when playhead is
scrolled off-screen, still visible when playhead is far to the
right of the scroll offset).
- Updated button state-scrubber expectations to match
BUTTON_STATE_TIMES (normal=1, hover=2, pressed=3, disabled=4) and
that hit-mode parks at normal=1 rather than 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ButtonClip transitions are now editable in ButtonInstanceDialog
(from/to/duration/easing) and persisted on the clip via the new
store.setButtonClipTransitions action; transitions are shared by
every instance of the button (clip-scoped, not instance-scoped).
- lerpProps exported from interpolate.ts so callers outside the
sampler can blend two poses.
- useButtonRuntime tracks an active transition per instance plus a
reactive transitionTick that pokes Vue computeds each frame.
A self-driving RAF loop runs only while some transition is active
(idles when none), so animations work paused or playing.
- applyButtonStateChange(id, to, transitions, persistedDisabled)
is the one entry point for state changes — it looks up a matching
from→to transition and starts it, or flips state instantly. Pointer
handlers in CanvasStage and store.actionRuntime.setButtonState both
go through it.
- CanvasStage blends the two state stacks via sampleStateLayers +
blendStates: layers matched by name interpolate their sampled props
with lerpProps; unmatched layers fade in/out. Renderer uses the
blended layer list in place of the pure state stack during transit.
- Export runtime mirrors the same flow: applyButtonChange starts
transitions, sampleButtonTransition drives per-frame progress, and
drawButtonStatesBlended composites via drawLayer(layer, 0, override).
drawLayer gained an optional propsOverride param for this.
RUNTIME_VERSION bumped to 6.
- Tests cover applyButtonStateChange matching logic and
sampleTransition's time → u mapping plus done flag.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Rename the app to RUWIO: title, APP_NAME, topbar logo (40px PNG),
welcome dialog header and about dialog switch from the PhSparkle
glyph to the real logo. Redundant name text next to the logo is
removed across the board.
- About dialog: logo spans the full dialog width, the bottom Close
button is gone (close via header X stays).
- Vertex bezier for polygons: each vertex can be Corner / Curve / Split
curve via right-click. Curve mirrors tangent handles symmetrically,
split curve keeps them independent. Tangents are fully animatable
per keyframe — lerp mirrored across all four render pipelines.
- Stage path overlay: selecting a keyframe draws the tween arc between
the previous and selected keyframe with draggable per-frame dots that
curve the path via quadratic bezier control points. Other segments
on the same track render as dashed ghost lines so the full curve is
visible. Path dots grow + show grab cursor on hover.
- Vertex box-select: click-drag on empty stage while a polygon is
selected boxes vertices. Shift-click toggles individual vertices
into/out of the selection. Multi-vertex drag moves all selected at
once; right-click on a selected vertex applies Corner/Curve/Split
to all. Tangent handles only render when exactly one vertex is
selected.
- Welcome dialog focus: opens with the most recent project focused.
Enter opens it; Tab cycles only through the dialog's real buttons
(delete icons pulled out of tab order, scrollable body removed from
tab order). Export-current removed from welcome (not useful on
startup).
- Modal focus trap: all modals now keep Tab inside the dialog.
- Fill / stroke transparency toggle restores the last real color when
flipped back on, with a layer-base / asset fallback chain so there's
always a visible color to restore to. A watcher keeps the restore
cache in sync with the sampled value.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Clipart library: new left-edge drawer (5px idle, expands on hover,
400px when open). User picks a local folder via the File System Access
API in settings; directory handle is persisted in IndexedDB so the
library survives reloads. Dragging a thumbnail onto the stage creates
an image asset + layer at the drop point.
- Animation paths on the stage: selecting a keyframe draws the tween
path from the previous keyframe to the selected one as a solid
polyline with per-frame draggable dots. All other segments on the
same position-track render as dashed ghost lines so the full curve
progression is visible.
- Path dots grow on hover (2.5 → 6 px), show a grab cursor, and have a
wider hit area so they're easier to grab.
- Bezier-curved position interpolation: keyframes gain optional
ctrlX/ctrlY fields. When set, x/y tween along a quadratic bezier
through the control point instead of a straight line. Dragging a
path dot solves for the control so the curve passes through the
drag point at its frame progress. Mirrored across all four render
pipelines.
- Tests: new suites for polygon actions, freeform drawing lifecycle,
polygon + stroke rendering (15 new tests). Fixed the mask-render
tests that relied on a default-transparent canvas background.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Freeform drawing tool: new sidebar button enters a click-to-draw mode
that appends points, shows a live ghost preview line, and exits via
Enter or double-click (near the start point closes the path). Works
end-to-end through all four render pipelines.
- Polygon authoring gestures: double-click on a path inserts a vertex on
the nearest edge; double-click on an open polygon's endpoint resumes
the drawing session from that end, reversing point order when needed.
- Stroke + closed-path properties: all shape types (rect, circle, text,
polygon) expose stroke color, stroke width, and (for polygons) a
closed toggle. Fill and stroke each support a "transparent" switch so
shapes can render as outline-only or fill-only.
- Mask preview no longer inherits the user's stroke — the dashed ghost
keeps its own outline regardless of stroke-width changes.
- Playhead no longer dims on inactive timeline tracks; only keyframes,
segments and clip blocks fade.
- Properties panel point-count control for polygons, plus per-keyframe
points animation that correctly interpolates once a baseline exists.
- Pivot drag hides the rotation handle until release.
- Transformer attach retries on a short timer to avoid races where the
Konva node hasn't mounted yet when selection changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Polygon asset type with per-vertex animation: new sidebar button inserts a
hexagon; vertex handles in the editor write per-keyframe `points` arrays
that interpolate between keyframes (requires matching point counts).
- Canvas mask preview: toggle in the zoom toolbar switches the stage
between the editable ghost view and a rendered view that applies the
actual masking via the shared canvas 2D path.
- Properties panel is grouped into collapsible sections (Transform, Text,
Appearance, Shadow, Mask) with per-section open/closed state persisted
to localStorage.
- Sidebar shape buttons are icon-only to reclaim width for the Polygon
entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>