New per-entity property that renders content N times around a pivot.
For clip instances each radial copy can also be staggered in time, so
arm i starts playing the clip i*offset frames later than the source.
Data model (src/types/scene.ts):
- Layer: kaleido?, kaleidoCount?
- ClipInstance: kaleido?, kaleidoCount?, kaleidoOffset?, kaleidoRotateCopies?,
kaleidoPivotX?, kaleidoPivotY?
All fields optional; legacy projects unchanged.
Math (src/lib/kaleido.ts):
- kaleidoCountFor / kaleidoAngleDegrees, clipKaleidoPivot, clipArmTranslate.
- clipArmLocalTime: returns the per-arm clip-local time. For arm i with
offset dt, the arm shows frame (offsetIntoBlock - i*dt) — visible only
when that falls inside [0, trimmedDuration]. Returns null otherwise so
the caller can hide pre-start and post-end arms.
Render parity:
- src/lib/render.ts: extracted renderClipInstance helper used by both the
top-level scene loop and drawTrackGroup. drawLayers + drawMaskShape now
loop the kaleido count and add the per-copy angle to the layer rotation
(Konva-style "rotate around (x,y)" comes for free).
- src/lib/exportPlayer.ts: mirror in JS (kalCount, kalAngle, clipKaleidoPivot,
clipArmLocalT, clipArmTrans, drawClipInstance). drawLayer + drawMaskShape
wrapped the same way. RUNTIME_VERSION 28 → 31 (3 fixes during landing).
- src/components/CanvasStage.vue: stackItems now emits one bundle per
kaleido arm with arm-specific items sampled at the arm's localT, so the
Konva live preview shows the staggered start in the editor — not just
in the fullscreen preview / export. Source arm (armIndex 0) keeps the
selection / drag / transform handlers; non-source arms are listening:
false. New clipInstanceKaleidoGroupConfig handles the per-arm transform
with rotateCopies on/off semantics. withK helper augments per-layer
configs with the kaleido rotation.
UI (src/components/PropertiesPanel.vue + i18n):
- New "Kaleidoscope" collapsible section. Layer mode: toggle + repeat.
Clip-instance mode (selected via canvas, panel banner explains the
partial editability): toggle, repeat, start offset (frames — converted
to seconds via scene.fps before storage), pivot X/Y, rotate-copies
toggle. Hidden for button-asset layers (hit-area routing breaks if
multiplied).
Store (src/stores/scene.ts):
- setLayerKaleido / setLayerKaleidoCount with [2, 64] integer clamp,
setClipInstanceKaleido with clear-semantics for offset=0 and
rotateCopies=true (deletes the field so JSON stays clean).
Tests (8 new under "kaleidoscope — store + render + engine"):
- Roundtrip + clamp for both Layer and ClipInstance setters.
- drawSceneFrame: 4 cardinal-pixel kaleido smoke for static layer.
- Button asset bypasses kaleido (hit-area routing intact).
- ClipInstance roundtrip + clear-semantics for offset=0.
- clipArmLocalTime: arm 0 honours bounds (returns null when source
itself is past), delayed arms outlive the source's window when
their shifted time is still in [0, dur].
- drawSceneFrame integration: 4-arm clip with 0.5s offset; sample at
t=0 / t=0.5 / t=1.5 verifies staircase visibility (1 → 2 → 3 arms).
- Engine source carries kalCount, kalAngle, drawClipInstance,
clipKaleidoPivot, clipArmLocalT; RUNTIME_VERSION '31'.
- layerActiveAt regression — kaleido fields don't break visibility gate.
Plus the prior export-runtime test updated to read drawClipInstance's
inst.{x,y,scaleX,scaleY,rotation} variables instead of the old inline
inst2.* names that the kaleido refactor removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Built-in additions: Montserrat, Nunito, Figtree, Gabarito (sans),
Archivo Black (heavy display), Press Start 2P (pixel/retro).
Custom font upload (lib/fonts.ts):
- uploadCustomFont(file) reads .woff2/.woff/.ttf/.otf via FileReader,
registers a FontFace early so a corrupt file fails before storage,
then persists {id, label, dataUrl} to localStorage under
'animatr:customFonts'. Quota errors surface a friendly message.
- removeCustomFont(id) drops the FontFace + storage entry. If the
asset still pointed at the deleted font, the picker resets it to
System Sans on the calling side.
- AVAILABLE_FONTS becomes a ComputedRef combining BUILTIN_FONTS with
the reactive customFontsState. fontFamilyMapSnapshot() rebuilds the
{id → CSS family} map per export so user-uploaded fonts make it
into the exported runtime.
FontPicker UI:
- Two sections divided by a thin rule — built-ins on top, "Your fonts"
below — plus an "Upload font…" entry at the bottom that opens a
hidden file input. Custom rows show a trash button on hover.
- Auto-selects a freshly uploaded font.
Engine source becomes a function (engineSource()) instead of a const,
so each export materializes the latest FONT_FAMILY map. ENGINE_TEMPLATE
holds the static skeleton with a __FONT_FAMILY_MAP__ placeholder.
Example HTML hint:
- Built-in fonts list their .woff2 path; custom fonts get a
"locally uploaded; @font-face below has the file inlined as a data
URL" line. The fontFaceCss snippet inlines data URLs for custom
entries so it's self-contained.
Bug fixes (player layerActiveAt):
- Mirrors interpolate.ts isLayerActiveAt now. The previous
`if(total<=1) return true` short-circuit made layers with a single
future keyframe visible at t=0; the lastHold→Infinity heuristic
ignored end keyframes entirely. Both regressions affected the
fullscreen preview and standalone exports. Replaced with a strict
scan: min = earliest non-end kf, max = earliest 'end' kf or +Inf.
Static layers (no normal kfs) stay always-visible.
Tests (5 new under "text font picker — model + render + export"):
- 6 new built-in ids in FONT_FAMILIES map.
- uploadCustomFont registers + persists + appears in AVAILABLE_FONTS;
removeCustomFont reverses it. Stubs FontFace and document.fonts.add
for the test environment via a stubFontFaceForTest() helper.
- uploadCustomFont rejects unsupported extensions.
- Exported runtime FONT_FAMILIES includes uploaded custom font ids.
- FontPicker popover shows the Upload font… entry.
- New "exported runtime layerActiveAt mirrors editor isLayerActiveAt"
extracts the function from engineSource() and exercises five
scenarios (single future kf, two normal kfs no end, end kf cuts
off, static layer always-visible, end kf wins over separated tracks).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the bare <select> with a custom combobox that fits the
properties panel's visual language:
- Trigger looks like the other inputs (var(--bg)/var(--border)/
var(--radius-sm)) and shows the selected font's name rendered in
that font itself, with a rotating caret.
- Options live in a teleported popover anchored beneath the trigger.
Each option renders its own label in its own typeface so the user
sees what they're picking before applying it. Active row uses
var(--accent-soft) to match the rest of the editor.
- Click-outside closes via window pointerdown; Escape closes and
returns focus to the trigger.
- Storage shape simplified: textFontModel uses '' for system-sans
instead of the literal 'system-sans' id, so the model field round-
trips through JSON without a placeholder string ever leaking.
Updated PropertiesPanel test to walk the new picker (click trigger,
read options from .font-popover, click an option) instead of
dispatching a select 'change' event. Picker label text doubles as
the test selector — System Sans / Inter / Permanent Marker confirm
the option list is wired up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New src/lib/fonts.ts: curated set (Inter, Roboto, Lora, Playfair
Display, Bebas Neue, Source Code Pro, Permanent Marker) shipped as
local .woff2 in public/fonts/. loadFonts() registers them via
FontFace at app start; fontsReady ref re-triggers Konva text configs
once fonts resolve so the first paint isn't stuck on the fallback.
- TextAsset gets optional fontFamily (id; undefined = system sans).
PropertiesPanel adds a font dropdown next to font size.
- All five render sites read the family: CanvasStage textConfig +
clipTextConfig (Konva), drawLayer + drawMaskShape (canvas2d),
exportPlayer engine (uses an embedded FONT_FAMILIES map and fontFam
helper; runtime version 27 → 28).
- Example HTML export now lists used bundled fonts and prints the
@font-face CSS the user has to paste alongside the .woff2 files —
fonts themselves are NOT bundled into the export to keep it small.
- Default text fill changed from yellow #f5d76e to black #000000 in
both the main sidebar and the SVG-scope add-text path.
- 8 new tests under "text font picker — model + render + export".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audited masking against the four-scope spec (groups, main timeline,
clips, SVG-internal). Rule 2 was already correct; rules 1, 3, 4 needed
fixes:
- drawMaskedGroup now walks children bottom-up and applies each mask
to the offscreen buffer immediately, so a mask only affects layers
below it in the stack — layers above the mask render unmasked on top.
- drawTrackGroup rewritten with an Item discriminated union collected
bottom-up, then a single z-order loop renders/applies, replacing the
old "collect all masks → apply at end" pattern.
- Both clip-rendering sites (drawSceneFrame top-level clip path and
drawTrackGroup's clip-child path) switched from drawLayers to
drawMaskedGroup — previously a mask layer inside a clip was rendered
as a regular shape instead of masking.
- 5 new tests under "mask layers — rendering" cover the four-rule
spec (group below-only, group negative below-only, clip-scoped mask,
SVG-asset mask scoped to interior, clip-internal layers above mask
unmasked).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Track-name click semantics in the Timeline:
- plain → single-select that layer
- shift → range-select every parent layer between anchor and click
(locked + locked-group children are filtered out)
- ctrl/cmd → toggle the clicked layer in/out of the selection
Anchor moves to the just-clicked id like a file manager.
- Polygon / freeform selection now lands at "level 1" — the regular
size + rotation transformer attaches like any other shape. A
double-click promotes to level 2 (vertex handles + edge-insert
dbl-click + tangent handles); selecting another layer drops back
to level 1. Same UX inside the SVG sub-editor.
- New layersUsingAsset(project, assetId) helper walks every layer
collection in the project (scenes, clips, button assets, svg
assets). All six polygon-mutation actions (addPolygonPoint,
removePolygonPoint, setPolygonPointCount, reversePolygonPoints,
movePolygonVertex, insertPolygonPointOnEdge) now use it, so
changes to the asset's points propagate to kf.props.points on
layers in any scope. Fixes "no node added on dblclick of a
freeform line inside an SVG asset".
- SvgLayerPanel buttons reset padding:0 — the global button reset
was eating the 22×22 row-button area with box-sizing:border-box
and clipping the Phosphor icons out of view.
- Duration grip at the right edge of the scrub ruler. Drag to
resize the active timeline (clip duration in clip-edit, scene
duration otherwise); maps clientX → seconds and snaps to the
scene's frame grid before calling setDuration. Hidden in button-
asset edit (state grid is fixed).
- Script-frame times go through a quantizeToFrame helper at the
store level (addScriptFrame / setScriptFrameTime / duplicateScriptFrame),
so drag input no longer produces values like 1.4181818… and the
playhead lands exactly on the frame during tick().
- Header time and DocumentDrawer duration input now show the
duration rounded to 2 decimals — frame-aligned floats like
4.733333… read as 4.73. Setter still writes the user-typed full-
precision value through unchanged.
- strokeCfg now defaults to strokeScaleEnabled:true so stroked rect /
circle / text outlines grow proportionally when the layer is scaled
(Konva default; previously hard-pinned to false).
- polygonConfig and clipPolygonConfig had inline stroke blocks that
also forced strokeScaleEnabled:false. Flipped both to true so
freeform / polygon outlines scale alongside the shape.
- SVG-asset placements use a dedicated svgChildConfig wrapper +
pre-multiplied strokeWidth in svgStackForAsset: each child's stroke
is bumped by the placement's geometric-mean scale and rendered with
strokeScaleEnabled:false (Konva resets the ctx to identity before
stroking) so the on-screen width lands at the right pixel count
regardless of how Konva propagates parent transforms to nested
stroke rendering. Konva-mounted tests cover outer-scale 1×, 3× and
non-uniform 4×/1×.
UI chrome (transformer borders, vertex / tangent handles, hit-shape
outlines, animation-path ghosts, ghost-locked layer indicators) keeps
strokeScaleEnabled:false intentionally — those should stay at constant
pixel width regardless of zoom.
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.
Press TAB to slide the asset sidebar and the properties aside off-screen
and let the stage absorb their grid columns; press again to bring them
back. Skipped while typing in inputs so form-field focus traversal
keeps working.
Adds a "Still image" subgroup under the project column in the export
dialog. Both options render the current playhead frame via the same
drawSceneFrame path used by GIF/APNG, so the still is pixel-identical
to the matching animated frame. JPG fills transparent scenes white;
PNG keeps alpha. Honours the per-export target size for higher-res
poster frames.
- Settings → Media tab now shows per-folder image/audio counts, skipped
subfolders, skipped files, and a rescan button. listClipart hard-skips
subdirectories and unsupported file extensions, returning a skip
report the panel renders.
- Empty-click anywhere — chrome, bare stage, empty timeline tracks —
clears the full selection (layers, clip instances, keyframes) via a
shared useDeselectAll helper. shouldChromeClickDeselect() splits
exclusions into interactive elements, overlays, editing panels and
timeline selection elements so empty timeline space deselects too.
- Export dialog goes wide (xl modal) and splits into two columns:
Project & code on the left (.ruw, runtime.js, animation.js,
example.html), Media on the right with the per-export size + fps
inputs followed by MP4 / WebM / GIF / APNG / APNG preview. Skip-
hidden toggle still spans the full width up top. Single-column
fallback below 720px.
- Welcome dialog reworked:
- Always auto-loads the last edited project on boot. The welcome
dialog opens on top only when the new "Show on start"
preference is enabled, or unconditionally when there are no
recents (first-run flow).
- Two-column body: Recent (max 5 thumbnail tiles, was 8) on the
left, Archive (compact list view, no thumbnails) on the right.
- Footer toggle binds to the new useShowWelcomeOnStart shared
ref so the user can opt out of the boot dialog inline.
- Document settings → Interface tab grows a matching "Show welcome
screen on start" toggle so users who turned it off can flip it
back on without diving into localStorage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Click-and-drag on the empty stage starts a layer marquee with
touch (not enclose) semantics — every unlocked layer whose bbox
overlaps the drag rect joins the selection. Tiny clicks still
deselect; shift adds to the existing selection on both clicks
and marquees. Polygon-vertex lasso stays available when a single
polygon is the active selection.
- Konva transformer now wraps every selected layer in one shared
bounding box (polygons fold in for mixed selections; vertex
handles + the vertex lasso suppress to avoid fighting the box).
rotateEnabled flips off on multi to skip the ambiguous shared-
pivot rotation. Drag of any selected node mirrors the delta onto
every sibling Konva node so the whole group travels together;
transformend persists each layer's final pose, not just the
anchor's.
- liveLayerPose became a per-id map (liveLayerPoses) so the
reactive shape configs don't snap non-focused siblings back to
their sampled pre-transform pose mid-gesture.
- Plain click on a member of an existing multi-selection no longer
collapses to single-select before the drag begins.
- Arrow-key nudge + Timeline + keyframe button + Properties panel
all fan out across the multi-selection: every editable layer
gets the value, kind-specific panel sections (text, polygon,
fill, stroke, blur, shadow, mask, button) collapse to leave just
Transform + Opacity, and the trash button labels with the
selection count.
- Click on empty stage with a polygon selected now deselects the
layer too instead of leaving the polygon stuck in the panel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Arrow keys now move the currently-selected object by 1 scene unit;
Shift bumps the step to 10. Works for both regular layers
(upsertKeyframe at the playhead — same write path as a stage drag)
and clip instances (setClipInstanceTransform). Locked targets and
events originating in text inputs are passed through untouched, so
typing in the document drawer / rename dialogs isn't stolen.
Also drops the flaky show-locked persistence test. It assumed the
in-memory default (true) differed from the stored value; on a
fresh dev-server restart with empty localStorage the first toggle
was a no-op, the watch never fired, and the assertion failed.
Test now flips the ref away first so every subsequent transition
is guaranteed to write.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New .ruw file format: a STORE-only zip wrapping project.json +
an assets/ folder. packProject extracts every data-URL asset
(images, audio, background video) into its own file and
rewrites the in-JSON src fields to relative paths;
unpackProject rehydrates back to data URLs on read. Zero
dependencies — we ship a minimal in-house zip codec.
- ExportDialog surfaces the new format as a top-row
"RUWIO project (.ruw)" option that round-trips through the
welcome-dialog file picker. Legacy .json imports still work
via byte-sniffing the PK header in readAnyProjectFile. Dead
App.vue `exportProjectFile` helper removed.
- Frame-based exports (GIF, APNG, APNG preview) take an optional
targetFps override so a 60fps project doesn't balloon the file
— APNG / GIF size scales ~linearly with frame count. The
ExportDialog grows a second input next to target size;
defaults inherit the scene's fps and reset on each dialog
open.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Canvas zoom + pan, crop / mask-preview / stage-preview / centre-
crosshair overlays, and timeline snap / show-locked / displayMode
/ px-per-sec / auto-fit now ride along in the saved project under
a new optional `editor` block. Reopening a project restores the
workbench exactly how the user left it — zoom level, pan offset,
HUD toggles, timeline density — instead of snapping back to
defaults.
Editor state lives on the store as a sibling of `this.project`
rather than inside it, so `JSON.stringify(store.project)` (the
undo/redo snapshot) still only captures content. The new
`projectForSave` getter stitches the two on the way to disk;
`loadProject` reads them back apart on the way in. Saves through
App.vue, the rename flow, and IDB recents all go through
`projectForSave`.
CanvasStage skips its first auto-fit when a non-default
canvas zoom/pan was restored so the user's exact view survives
mount. Frame-based export options pick up the same source of
truth for a consistent starting point.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Frame-based exports (GIF, APNG, APNG-preview) accept an optional
target output size. ExportDialog exposes it as a single width
input above the three image-export rows; height is derived live
from the scene's aspect ratio so the user can't squash the output.
The preview HTML mirrors the target dims so a downsized export
previews at its real pixel size.
renderFrames takes targetWidth / targetHeight, sizes its canvas
accordingly and scales the ctx once; drawSceneFrame's own
save/restore preserves the transform across frames, including the
top-level-mask offscreen compositing path.
Target width on dialog open is inherited from the current
scene.canvas — watched via modelValue so resizing the stage and
reopening the dialog picks up the new size (instead of the stale
value from whenever ExportDialog first instantiated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First stable release. Highlights in this bump:
- Group context menu gains a proper danger "Delete group" entry
that tears down the group AND every track it contains (layers,
clip tracks, script tracks, audio tracks) along with their
dependent content. "Ungroup" keeps its old meaning as a separate
non-danger item, so the two operations stop being confusable.
New removeTrackGroupWithChildren store action clears any
selection pointing inside the group before dispatching per-kind
removes.
- APNG preview page now renders the export at its native pixel
size: the .stage + <img> use explicit width/height in pixels
instead of a max-960 aspect-ratio box with object-fit: contain.
A 4K export looks 4K; the body scrolls if the window is
smaller.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fix mask-preview / frame-export dropping any group whose children
are clip tracks. render.ts's old group branch only pulled Layer
children out of group.childIds; new drawTrackGroup walks both
layer AND clip-track ids and still applies group-local masks to
the composite. Same fix mirrored into the HTML export runtime in
exportPlayer.ts.
- Snapping magnet for the playhead: snapTime now includes
store.playhead in its candidate list by default. Clip drags, trim
handles, keyframe drags, marker drags and in/out brackets stick
to the needle when snap is on. Scrub handlers pass
ignorePlayhead:true so the needle doesn't stick to its own prior
position during a scrub.
- Clip trim handles derive the snapped edge first and back out to
trimStart/trimEnd, so the edge lands exactly on the magnet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Optional centre crosshair: dashed horizontal + vertical lines
through the effective canvas centre, togglable from the HUD next
to the crop / mask-preview / stage-preview buttons. Scales with
zoom and stays visible inside clip-edit.
- Locked clip tracks now behave as if they weren't on the stage —
the ci-group is listening:false + draggable:false, so selection,
drag, and transform all pass through. deselectIfLocked also
clears a clip-instance selection that just got locked out
(directly or via a parent group).
- Eye toggle stays clickable on locked layer / clip-track rows —
previous pointer-events:none rule only whitelisted the lock
button. Hide-in-editor is orthogonal to lock.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Track groups get the same editor-hide affordance as layer and clip
tracks: new eye button on the group header, setGroupEditorHidden
action (clears selection pointing inside the group), and a cascade
through the Konva stackItems + render.ts so every child track in a
hidden group vanishes in the editor.
Track renaming via right-click context menu now covers layers and
clip tracks too — setLayerName / setClipTrackName actions, new i18n
strings, and end-to-end UI tests driving the menu → promptDialog →
rename flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each clip instance now renders an invisible frame rect sized to the
union of its layers' footprints across every keyframe. The
transformer wraps that frame so its handles stay on the clip's own
outer constraints instead of chasing the currently-visible inner
shape, and the rect doubles as a hit region so empty-playhead
frames remain selectable. A plain click on a timeline clip-block
also selects the instance (drag still takes over past the 2px
dead-zone).
Clip tracks pick up the same editor-hide affordance as layer
tracks: new eye button on the track-name, setClipTrackEditorHidden
action, and filters in the Konva stackItems + render.ts so
hidden-track instances vanish in the editor. ExportDialog's
skip-hidden opt-in now also strips hidden clip tracks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Right-click an SVG-backed layer to split it into one layer per
top-level drawable. Each part keeps the original stage position
and ends up as a same-size standalone SVG.
Also: delete confirm now floats above the welcome dialog (topmost
z-index + scoped ESC/Tab via modal stack) and autofocuses its
primary button so Enter triggers confirm instead of bubbling back
to the underlying row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Up/Down arrow keys now move focus through the recents list in the
welcome dialog; Home/End jump to the first/last entry. Tab / Enter
/ Space behaviour is untouched — arrows are additive. Covered by
two UI tests (arrow roundtrip + Home/End) that poll for the async
IDB load instead of a fixed delay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New frame-by-frame export pipeline in src/lib/frameExport.ts: loads
scene images, walks rangeIn..rangeOut at scene.fps, drawSceneFrame
to an offscreen canvas, awaits a per-frame callback, yields every
4 frames to keep progress responsive, honours AbortSignal.
- Animated GIF via gifenc (per-frame quantize/palette, ~5 KB lib).
- Animated PNG via UPNG.js + pako (lossless RGBA, alpha-aware, ~30 KB).
- drawSceneFrame now clearRects first and skips the background fill
when scene.canvas.background === 'transparent' — prerequisite for
the alpha to reach the APNG output.
- DocumentDrawer: new checkerboard toggle next to the colour picker
flips the stage between its last-picked colour and transparent;
disabled swatch + 'Transparent' hex label make the state obvious.
- ExportDialog: three new option rows — Animated GIF, Animated PNG,
and an APNG preview page that bundles the rendered APNG into a
standalone HTML with a checkerboard backdrop + dark/light toggles
for verifying transparency at a glance.
- Tests: renderFrames frame count + abort, GIF header magic, APNG
acTL presence, transparent round-trip (decoded alpha == 0 on
un-painted pixels), preview HTML structure, and the two UI tests
covering ExportDialog's new rows and the progress-view transition.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Right-clicking the stage opens a small menu with Document settings
(pops the document drawer) and Resize stage. Closes on outside
click and ESC.
- Resize stage drops eight accent-coloured drag handles around the
canvas rect (N/NE/E/SE/S/SW/W/NW). East/south handles just grow or
shrink the canvas; west/north (and their corners) also shift every
scene-world coordinate in lock-step so content stays pinned to its
visible position while the canvas re-aligns around it.
- Enter forces stage-crop on so the user can see where the new edge
lands; exit restores the previous crop setting. Exit paths: Done
button in the floating banner, ESC, or the restored crop toggle.
- New store action shiftSceneWorldCoords(dx, dy) covers layer base +
main/position keyframes + bezier control points + clipInstance x/y
across every scene page. Clip-library layers, button assets, hit
shapes and polygon vertex clouds stay put — those live in local
coord systems, not scene-world space.
- Tests: store coverage (shift across pages, positionKeyframes,
ctrlX/Y, clipInstance, clip-library untouched, (0,0) no-op) plus UI
coverage (context-menu items, Resize stage enters mode + renders 8
handles, ESC and Done both exit, crop forced on and restored).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Clipart store holds an array of root folders instead of a single
handle, plus an activeHandleIndex for the in-folder view. Getters:
activeHandle, inOverview, hasAnyFolder, canGoBack, folderName.
- Actions: addFolder (appends a root), enterFolder, exitFolder,
removeFolder. Single-folder installs auto-drill into the sole root
and behave exactly like the pre-refactor drawer; adding a 2nd+
folder pops back to the root overview.
- IDB persistence migrated from the 'clipartDir' single-handle key to
a 'clipartDirs' array. Legacy records are migrated on first load.
saveClipartFolders unwraps Pinia proxies (toRaw per element) so
IDB's structured clone accepts the payload.
- ClipartItem union gained a 'folder' kind so root tiles render as
first-class media items. listClipart emits nested directories too,
but the drawer keeps drill-down one level deep for now.
- ClipartDrawer header: three equally-sized .header-btn chips —
back (only when canGoBack), refresh (when viewing a folder), and a
new `+` (when any folder is attached). No more big 'Choose folder'
button — empty state is a centred 96px dashed + with a wrapped hint.
- Folder tiles carry a hover `×` to remove a root from the library.
- DocumentSettings media tab lists all configured folders with a per-
folder remove action; primary button is now 'Add folder'.
- Pinia store now calls acceptHMRUpdate so Vite HMR actually swaps
the store definition between reloads instead of leaving the first-
load state shape behind.
- Tests: store actions (addFolder / enterFolder / exitFolder / remove
/ IDB round-trip), drawer UI (centred + empty state, overview with
folder tiles + click-to-enter, back visibility, single-root hides
back, header + button triggers addFolder). Tests isolated with
try/finally so a failing store assertion can't leak error state
into subsequent drawer tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
addTrackGroup takes an optional anchorTrackId and inserts directly
above it in trackOrder; anchors nested inside another group resolve
to their parent's slot. The timeline's group-add button forwards
selectedLayerId so the new group lands where the user is working
instead of always jumping to the top. Covered by store + UI tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Scripts bumped from 26px to 30px so the five track kinds read as one
visual list; clip / script / audio tracks lose their kind-specific
gradient backgrounds now that they're no longer fixed-position
chrome.
- Audio tracks pick up the standard drag attributes + handlers; they
now participate in the same trackOrder flow as layer / clip / script
tracks and can be dropped into groups.
- normalizeScenePage accepts scripts + audio as group children (used
to silently strip them); the per-kind ordered appending is replaced
with a single tail loop so no kind is hardcoded to the bottom.
- moveTrackIntoGroup color cascade now covers all four groupable kinds
(scripts + audio inherit group colour like layers + clip tracks).
- Group expansion in Timeline emits scripts + audio as first-class
sub-track children, with the shared in-group / sub-track classes
and group-aware drag handlers.
- Tests: move-into-group + color cascade for scripts + audio, legacy
childIds roundtrip preserves both kinds, audio track is draggable,
script track is 30px.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Document drawer slides out from under the topbar (no tab, no hover
peek); opens via project-name button, closes on outside-click or ESC.
Layout: inputs row + divider + preset row, presets user-managed via
popup and persisted in localStorage.
- PWA manifest + minimal service worker so Chrome installs RUWIO as a
standalone app — suppresses the native fullscreen exit overlay.
- Clips and audio clips get right-click context menus (Duplicate /
Delete) via new `duplicateClipInstance` / `duplicateAudioClip` store
actions.
- Clip moves cascade non-overlap in both directions; `setAudioClipPosition`
now pushes backward as well as forward. New `setClipInstancePosition`
gives clip instances the same behaviour.
- Library drags carry timeline-shaped ghost previews (audio pill, clip
pill, button thumb) instead of the library row; clip lane renders a
dashed drop-target ghost and rejects window-overlap drops.
- Scene / Clipart drawer tabs disappear in fullscreen; the 24 px they
occupied is reclaimed for the stage. Play button sits on the exact
horizontal center via a 3-column grid in the transport cluster.
- Tests: context menu + push-cascade + duplicate action coverage for
both clip kinds; DocumentDrawer suite rewritten for the slide-out
model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-select
- New `selectedLayerIds: string[]` on the scene store — ordered by
click, anchor = last entry, always equal to `selectedLayerId`.
- `toggleLayerSelection(id)` shift-click behaviour: adds or removes,
anchor follows the last click, effectively-locked layers refuse.
- `selectLayer`, `addLayer`, and every selection-reset path keep
the set in lock-step with the anchor.
Timeline
- Shift-click on a layer row's name toggles that layer's
membership in the multi-selection. Every selected row highlights
via `store.selectedLayerIds.includes(track.layerId)`.
- Right-click on a track now preserves an existing multi-selection
if the clicked track is part of it; lands as a single-select
otherwise.
- Track context menu adds two entries when ≥1 layer is selected:
- "Group N selected layers" — calls the new store action and
drops the group into the topmost selected track's stack slot
(not at the very top as a naive `addTrackGroup` would).
- "Convert N selected to clip" — packs the layers into a fresh
clip with `duration = scene.duration`, deep-clones via
`structuredClone(toRaw(l))`, drops a single `ClipInstance` on
a new clipTrack at `startTime = 0`, and positions the
clipTrack at the topmost selected track's slot.
Fixes
- `removeLayer` no longer unconditionally clears
`selectedLayerIds` (bulk-replace artifact from the multi-select
work); the anchor-matched path clears the whole set, the
non-anchor path only splices out that one id.
- `deselectIfLocked` indentation normalised.
Regression tests
- Multi-select store API: selectLayer sync, toggleLayerSelection
add/remove/anchor, lock-filter, empty-selection guards.
- `groupSelectedLayers`: happy-path packing, empty-select null
return, lock-filter, stack-slot pinning.
- `convertSelectedLayersToClip`: packs layers + spawns clipTrack +
instance (deep-clone integrity pinned via unique per-layer names
so the cloned-layer lookup is unambiguous), drops the clipTrack
at the topmost slot, returns null on empty selection, skips
effectively-locked layers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>