Skip to content

Keyframe/timeline animation editor (replaces hardcoded slides)#9

Merged
srperens merged 28 commits into
mainfrom
feat/keyframe-timeline-animation
Jun 10, 2026
Merged

Keyframe/timeline animation editor (replaces hardcoded slides)#9
srperens merged 28 commits into
mainfrom
feat/keyframe-timeline-animation

Conversation

@srperens

@srperens srperens commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

The headline "close the gap" feature: a real per-element keyframe animation editor, replacing the two hardcoded slide animations. It has since grown to also make the editor's output portable and spec-clean (zip + manifest export/import) and to harden the generated graphics against injection. Built against the UX design and the OGraf v1 spec brief.

Animation engine

  • Timeline model under the vendor key v_ografEditorTimeline, so the manifest stays additionalProperties:false-clean. Per element: in (play) and out (stop) lanes of keyframes {t, props:{opacity,tx,ty,scale}, easing} plus a per-lane delay.
  • The generated component animates via the Web Animations API. playAction/stopAction await animation.finished (no lying setTimeout) and honor skipAnimation (snap to end).
  • Publishes honest actionDurations (real computed ms), clamped to a finite integer so the manifest stays schema-valid.

Timeline UI

  • Collapsible Timeline panel docked at the bottom of the editor (drag-resize, keyboard operable, state persisted). Hidden in the Preview tab so there is only ever one Play/Stop transport in view.
  • Simple path: the sidebar Animation section is "Animation (quick presets)" (None/Fade/Slide/Pop) that generate keyframes, so a non-developer is never forced into the timeline.
  • Advanced path: per-element in/out lanes, keyframe add/move/edit (numeric opacity/offset/scale plus easing and delay), playhead/scrubber, local Play/Stop/Snap preview.
  • Guardrail: a lane hand-edited in Advanced is marked custom in the model, and re-applying a Simple preset asks for confirmation before overwriting it.
  • WCAG-oriented: focusable keyframes, slider playhead, grouped and labelled tracks and lanes.

Portable, spec-valid export/import

  • Export the graphic as a single .ograf.zip bundle (the <id>.ograf.json manifest plus the .mjs component), or as the spec package files directly.
  • Import is symmetric: a .ograf.zip, a raw .ograf.json manifest, the editor JSON bundle, or the manifest and .mjs chosen as separate files. The component is picked by the manifest main field.
  • Authored elements and the timeline ride along under v_ vendor keys, so an export/import round-trip preserves the full authoring state. Import is scoped to editor-made templates (a foreign code-first graphic is declined for visual editing rather than reconstructed misleadingly).

Secure rendering (XSS hardening)

  • Runtime data (operator/feed input via load()/updateAction()) is escaped before it reaches shadow-DOM innerHTML; image src is restricted to http(s)/data:image (safeSrc); element ids are slug-restricted to [a-z0-9-].
  • CSS injection closed at the generator: sanitizeCssValue rejects backtick and ${ (a style value is spliced into a JS template literal in the generated .mjs, so a backtick could close it and inject code), and CSS property names are validated as plain dashed identifiers at both build time and runtime. Reachable now that arbitrary .ograf.zip/.json files can be imported.

UI polish and editing

  • Element toolbar moved to the top of the visual editor with a consistent inline-SVG icon set (replacing mismatched glyph/emoji buttons).
  • Editable element ID with a safe rename (migrates styles, animation lanes, and the timeline key atomically).
  • Cyan header accents so the dark UI reads more clearly.

Docs

  • README gains an export verification flow (ajv against the published schema, the ograf-devtool reference checker, end-to-end in a real renderer such as SPX).

Deferred (noted): rotation, cubic-bezier curve editor, multi-step (GAP-C), drag-on-canvas to set keyframe values.

Verified: lint clean (0 errors), 110 tests pass across 14 files, build clean. Browser-verified the toolbar icons and the Preview-tab timeline hiding; broader visual review of the panel is ongoing.

🤖 Generated with Claude Code

srperens and others added 28 commits June 9, 2026 15:11
GAP-B foundation (engine). Replaces the two hardcoded CSS-transition slides with
a timeline-driven model that compiles to the Web Animations API in the generated
component, so animation is spec-correct and portable.

- Timeline model stored under the vendor key v_ografEditorTimeline (manifest is
  additionalProperties:false, so a v_ key is the spec-safe home). Per element:
  in (play) and out (stop) lanes of keyframes {t, props:{opacity,tx,ty,scale},
  easing}, with per-lane delay. getTimeline() repairs older/partial shapes.
- The Simple slide/fade presets now GENERATE keyframes into the timeline, so the
  existing quick-preset path is preserved and feeds one source of truth.
- Generated component animates via element.animate(); playAction/stopAction await
  animation.finished (not a setTimeout that can lie), and honor skipAnimation by
  snapping to the end state.
- Publishes honest actionDurations (real computed ms per play/stop) so on-air
  automation can schedule correctly.
- Element removal drops its timeline lanes. Tests updated for WAAPI output.

The visual timeline panel UI (Advanced keyframe editing) lands next as GAP-B.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-ups on the animation engine, both latent until the Advanced
keyframe editor lets users type arbitrary times:

- computeActionDuration now clamps to a finite, non-negative integer. The OGraf
  schema requires actionDurations[].duration to be an integer, and a non-finite
  value serializes to null (an invalid manifest); guard at the model layer.
- buildLaneEffect dropped its unused totalDuration param and the misleading
  "shared clock" comment: each element animates over its own span offset by its
  delay, which is what the code actually does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GAP-B.2 (the visible editor on top of the GAP-B.1 engine).

- A collapsible Timeline panel docked at the bottom of the editor area (drag to
  resize, arrow-key operable, collapsed/height persisted), operable on the Visual
  tab and collapsed to its header otherwise.
- Simple path: the sidebar Animation section becomes "Animation (quick presets)"
  with None/Fade/Slide/Pop chips + duration + easing that generate keyframes.
- Advanced path: per-element in/out lanes with keyframe add/move/edit (numeric
  opacity/offset/scale + easing + per-lane delay), a playhead/scrubber, and local
  Play/Stop/Snap preview on the canvas.
- Simple/Advanced guardrail: a lane edited in Advanced is marked custom; a preset
  cannot silently overwrite it (explicit confirm + Custom badge).
- Keyboard operable and labelled per WCAG (focusable keyframes, slider playhead,
  grouped tracks/lanes). Tests for the model edits and the panel DOM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P1: colorToHex now parses rgb()/rgba() and 3/4/6/8-digit hex, expanding
short hex and dropping alpha for the opaque swatch. rgba() with partial
alpha returns null so the authored value (and its alpha) is preserved
rather than overwritten by the swatch fallback. The swatch only writes
its hex into the text field on an explicit user pick, never on the
programmatic render-time value assignment.

P1: 'transparent' no longer maps to #000000. It returns null, so touching
the swatch on a transparent element no longer paints it opaque black; the
authored 'transparent' value stands unless the user picks a real color.

P2: commitDataInputKey no-ops when the input is not connected, so the
trailing blur after a rename-driven re-render does not recommit against
stale state or focus a detached node.

P2: a blank Slide In/Out Duration field is treated as no change instead of
Number('') === 0, so clearing the field mid-edit never stores a 0ms
animation.

Adds test/bugfix-propertypanel.test.js covering colorToHex across rgba,
3/4/8-digit hex, transparent, and named colors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PreviewEngine had several verified bugs around the generated-component
lifecycle. The generated component bakes elements/styles/timeline at
generateWebComponent() time and never reads animationSettings, so the
only correct refresh path is to rebuild the component.

- reloadComponent: remove the always-true animationSettings short-circuit
  that wrote a field the component never reads and returned early, so live
  preview never refreshed after an edit. Always take the recreate path.
- reloadComponent recreate-while-playing: after a successful re-create set
  isPlaying + updateControlButtons and await playAction with a catch that
  routes failures to showWebComponentError, keeping isPlaying correct.
- render: tear down the previous component synchronously before scheduling
  setup, store the setup timeout id and clear it on re-render/teardown/
  destroy, and bail in the scheduled setup if the template changed. Fixes a
  stale-template race on fast template switch / select-then-play.
- showWebComponentError: guard against a null scaledContainer (log and
  return instead of throwing). stop() with no component is now a silent
  no-op that just resets play state instead of showing a scary error.
- renderDataInputs: seed and read previewData with presence checks so
  falsy numeric/boolean defaults (0/false) survive instead of becoming ''.

Adds test/bugfix-preview.test.js covering the recreate path, the no-op
stop, the null-container guard, and falsy-default preservation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P1: TemplateManager.saveToStorage no longer swallows save failures (e.g.
QuotaExceededError). It now records a flag, logs a clear error, and dispatches
an ograf-storage-error CustomEvent so the app can surface it. loadFromStorage
logs corrupt top-level JSON and per-template parse failures and continues with
an empty/partial set instead of silently dropping everything.

P2: OGrafTemplate.addElement now combines the timestamp with a random suffix
and guarantees uniqueness against existing ids, so elements added within the
same millisecond no longer collide.

P2: createElementsFromSchema de-duplicates generated element ids, so schema
keys differing only by case or punctuation (e.g. Title vs title) no longer
collapse into one id and break style/animation lookup.

P2: A spec manifest/bundle round-trip is no longer lossy. buildManifest now
persists the authored editor elements under v_ografEditorElements (and timeline
under v_ografEditorTimeline when present); both are spec-valid vendor keys
allowed by the OGraf v1 graphics schema patternProperties ^v_.* even under
additionalProperties:false. importTemplate restores elements from that vendor
key (ids sanitized via slugifyId) instead of regenerating defaults from the
schema, preserving authored geometry, style, content, and non-text elements.

Adds test/bugfix-model-io.test.js covering id uniqueness, schema-id dedup,
visible save-failure handling, corrupt-load handling, and the bundle round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
VisualEditor:
- Remove the double-counted panOffset in hit-testing, drag, and resize (the
  transform already folds pan into getBoundingClientRect), so clicks hit the
  right element and resize anchors hold when panned.
- Recompute the moving edge from the fixed edge after the min-size clamp so
  NW/NE/SW resize no longer slides the anchored edge.
- Ignore the Delete key when an editable field is focused, so typing in a
  property input no longer deletes the selected element.
- Bind event handlers once so destroy() actually removes them (no leaked
  document keydown deleting elements in a new editor).
- Replace the defunct via.placeholder.com default image with an inline data URI.

TimelinePanel:
- Align the ruler/playhead with the keyframe lanes (shared x-origin), so
  keyframes no longer sit ~256ms off the playhead.
- Reset stale element selection on template switch (no phantom lanes).
- Seed an added keyframe by interpolating the lane at the playhead instead of a
  hardcoded rest state.
- Restore inspector focus across re-render on a value commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…meline-animation

# Conflicts:
#	src/components/PropertyPanel.js
The element ID field was readonly, so users could not change an element's id,
confusing and limiting. It is now editable and commits on change/blur (never
per keystroke).

- OGrafTemplate.renameElementId(oldId, newId) validates the new id (slug-safe,
  unique among elements), updates element.id, and migrates the element's
  timeline lane key under v_ografEditorTimeline. Data tokens ({{...}}) are a
  separate namespace and are untouched.
- The panel validates before renaming (inline aria error on empty/invalid/
  duplicate, focus kept), then saves, regenerates, keeps the element selected
  under its new id, and refreshes canvas/timeline/preview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…robust offsets

BUG 1: elements that should start hidden flashed visible on the first
painted frame, and stayed visible during a lane delay. playAction()
rendered elements at their resting (visible) state and let the browser
paint that frame before the WAAPI in-animation hid them. Add
applyInitialState(action) on the generated component: it reads each
animated element's offset:0 keyframe from buildLaneEffect and writes
opacity/transform to the inline style synchronously, BEFORE the rAF/paint.
Call it in playAction() after render() (before the rAF) and in load()
after render(), so a loaded-but-not-played graphic also shows its
in-start state. Elements with no in-lane stay at their resting state.
fill:'both' still holds the visible end state when the animation finishes,
and skipAnimation still snaps correctly.

BUG 2a: editing keyframes in the Timeline panel did not refresh the live
preview, so edits appeared to do nothing. Wire TimelinePanel.persist() to
call previewEngine.reloadComponent() (mirroring the PropertyPanel path),
add setPreviewEngine() and connect it in main.js, with a fallback to
window.ografEditor.previewEngine.

BUG 2b: buildLaneEffect dropped the authored first value when the earliest
keyframe was at t>0 (WAAPI synthesized offset:0 from underlying style).
Always prepend an explicit offset:0 frame holding the earliest authored
value when the first frame is not at offset 0, in both the generated
component and the TimelinePanel local-preview mirror, so offsets stay
monotonic in 0..1 and intermediate waypoints render.

Verified in a real browser (headless Chrome, CDP): a fade-in shows
opacity 0 at load and at play-start, ~0 through an 800ms delay, and
ends at opacity 1; a slide-in keeps opacity at rest and applies transform.

Tests: add test/bugfix-animation-faithfulness.test.js (8 cases). Full
suite 93 passed, lint 0 errors, build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Keyframe focus calls (select, keyboard move, edit, focus restore) now pass
  { preventScroll: true }, so grabbing a keyframe far to the right no longer
  makes the browser scroll the page to reveal the focused element. scrollIntoView
  for a selected track uses nearest on both axes so it never scrolls the page.
- The Templates list had two nested scroll areas (.template-list-content and an
  inner .template-list-container with its own max-height + overflow). Removed the
  inner scroll so the panel is the single scroll area.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… scrollbars

- The playhead now sweeps across the ruler in real time during a local Play
  (jumps to the end on Snap, resets to 0 on Stop), with the sweep loop cancelled
  on Stop/re-render/destroy so it never leaks or double-runs.
- Added an In/Out toggle to the timeline transport so Play/Snap can preview the
  out animation, not just the in animation.
- Fixed the real "grabbing a keyframe scrolls the view to the top": render()
  rebuilt the panel innerHTML, resetting the scrolled track list and lanes. We
  now preserve and restore the timeline-body scroll and each lane's horizontal
  scroll across the rebuild (preventScroll on focus alone could not fix this).
- Added slim dark-theme custom scrollbars (Firefox + WebKit) so the sidebar,
  timeline, and other scroll areas match the rest of the UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The add-text/image/rectangle/circle toolbar was below the canvas. Move it above
the canvas so the tools are at the top of the visual editor, and flip its border
to the bottom edge accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep the dark theme, but lift the all-grey look with the OSC brand cyan
(#4dc9ff) on header text: a cyan-to-blue gradient app wordmark, cyan panel
headers (Templates/Properties) with a blue accent bar, cyan collapsible section
headers, cyan active editor/code tabs, and a cyan uppercase Timeline label.
Interactive borders and focus rings stay #007acc for consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Export button used the editor-bundle 'json' format, producing a single
<id>-template.json. Per the EBU OGraf v1 spec a Graphic is a manifest whose file
name MUST end with .ograf.json plus the Javascript module the manifest's "main"
references. TemplateManager.exportTemplate already builds exactly those two files
(<id>.ograf.json + template.mjs); switch the Export action to emit them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the fake JSON "zip" (which just wrote a .json) with a real, dependency-
free stored-ZIP writer, and point Export at it. One click now downloads a single
<id>.ograf.zip containing the spec files: the <id>.ograf.json manifest and the
template.mjs component. Adds a zip util (CRC32 + ZIP local/central/EOCD records)
with tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the three levels of verifying an OGraf export: JSON Schema
validation against the published schema, the ograf-devtool reference
checker, and an end-to-end playout test in a real renderer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The element toolbar mixed a plain letter, a color emoji, and two thin
unicode glyphs, so the buttons rendered at different sizes, colors, and
baselines. Replace all four with a uniform inline-SVG icon set using
currentColor so they align and inherit the hover/active state, and tidy
the button styling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Import now accepts the exported .ograf.zip: it unzips, reads the .ograf.json
manifest (which carries authored elements/timeline under v_ vendor keys), and
keeps the exact .mjs component when present. Single .ograf.json import still
works; importing a bare .mjs gives a clear message. Adds a ZIP reader (stored +
deflate via DecompressionStream) and a createZip/readZip round-trip test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add TimelinePanel.setVisible(), which main.js calls on tab switch so the
bottom timeline (and its Play/Stop transport) is hidden in the Preview
tab, leaving only the preview's own transport. Hiding also stops any
in-flight local preview. Also remove the per-lane Custom badge and its
CSS, which read as noise in the timeline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The import dialog now allows multiple selection, so a user with the loose
files (not a zip) can pick the .ograf.json manifest and the .mjs component
together. importFromFiles finds the manifest (and a .zip or .mjs among the
selection), imports the manifest, and keeps the .mjs as the component. The
zip and single-manifest paths are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per scope decision (A), the visual editor only opens templates it created:

- Import now refuses a code-first OGraf graphic (no v_ografEditorElements) with a
  clear message, instead of fabricating misleading default elements from the
  schema. Our own .ograf.json/.zip/editor-bundle still import (they carry the
  v_ authoring data).
- Zip and multi-file import select the component by the manifest's "main" field
  (exact name or basename), falling back to the first .mjs/.js, so a graphic with
  a lib/*.js no longer risks picking the wrong file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review caught a regression: the editor JSON bundle keeps elements in a top-level
field, not in the manifest, so the new "no v_ografEditorElements -> refuse"
guard threw before importEditorTemplate could restore them. Fold the bundle's
elements/timeline into the manifest vendor keys so the importer accepts it, and
keep the local element id-sanitize so it works regardless of the manager. Adds a
regression test for editor-bundle re-import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dead code

- sanitizeCssValue now rejects backtick and ${, not just {}<>". A style
  value lands inside a JS template literal in the generated .mjs source, so
  a backtick could close it and inject executable code (reachable via an
  imported .ograf.zip/.json). (HIGH)
- Validate CSS property names (sanitizeCssKey / inlined safeCssKey) at both
  build time (generateElementStyles) and runtime (the generated component's
  renderElement); a crafted style key could otherwise break out of the rule
  or the style="..." attribute. Invalid keys are dropped. (MEDIUM)
- VisualEditor design canvas now only loads http(s)/data:image img.src,
  matching the generated component's safeSrc guard; other schemes and
  unresolved {{tokens}} fall back to the placeholder. (LOW)
- Remove computeActionTotal, dead code shipped into every exported .mjs.

The dead exportTemplateBundle/importTemplateBundle methods were already
removed upstream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@srperens srperens merged commit 6c8f8eb into main Jun 10, 2026
@srperens srperens deleted the feat/keyframe-timeline-animation branch June 10, 2026 09:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant