Interactive mode
In interactive mode the composition runs as a lesson that plays one step at a time and holds for the learner, and in video mode the same scenes play straight through.
A step is a clip with an id and data-step, and wildflower.kinetic.step(id, { wait, until, entry, script }) gives it what markup cannot carry.
check() and render() run in video mode, where a scripted store path takes its value from the scripts alone.
Script values are real store writes, so watchers and autoSave see them, and interactive-only effects go in action handlers, entry or the kinetic:step event.
Interactive mode
A scene reads a store path the same way in both modes, whichever mode wrote it.
- In video mode the clock plays the composition straight through,
check()andrender()seek it, and each step's script writes its store paths on every seek. - In interactive mode the composition is a lesson on the page, each step plays its range and holds on a frame, and the learner's controls write the same store paths.
A change to a scene's state while the clock stands still seeks the time again, once a frame, so the scene's
seek(local), its effects and its shade layers follow a control moved during a hold.
A hold freezes the clock, so local stands still while a step waits, and what the learner changes moves only through follow springs or bindings to the store.
Design a step's hold around a control and what follows it, and put the motion that plays by the time before the hold or after it.
A composition with no steps, scripts or followers is the same in both modes.
The modes
config({ mode }) takes 'auto' (the default), 'interactive' or 'video', and the store's mode holds the mode in force, 'interactive' or 'video'.
| Situation | Mode |
|---|---|
A renderer's runtime on the page, or window.__HF_RENDER_CAPTURE_MODE set | video, whatever the setting |
While check() or render() runs | video, then back |
'video', or 'auto' with the preview bar up | video |
'interactive', or 'auto' on a plain page | interactive |
A lesson page with data-preview on the extension's tag is therefore in video mode, where its steps never hold and its controls stop working, and development builds warn (KN-158).
For the lesson, remove data-preview, or call config({ mode: 'interactive' }) to keep the bar.
Switching to video mode sets aside the learner's values of the scripted paths and writes the scripts' values for the current time, and switching back restores the learner's.
After their own seeks, check() and render() finish in this order, so the seek back never enters a step in interactive mode.
- They seek back to the time they found, still in video mode.
- They write the learner's values back.
- They return to interactive mode, and play on a clock that was playing.
// A "watch it as a video" button, and the way back to the lesson.
watchVideo() { wildflower.kinetic.config({ mode: 'video' }); wildflower.kinetic.play({ from: 0 }); },
backToLesson() { wildflower.kinetic.config({ mode: 'interactive' }); wildflower.kinetic.go(this.returnTo); },
Steps
A step is a clip with an id and data-step inside the composition root, and its id is its name.
Steps chain with relative starts, since a relative start refers to a clip by its id.
A step's content goes inside its element, and content that outlasts it goes in a clip of its own, timed from the step.
<section id="circle" data-step data-start="0" data-duration="6"><p>A point goes round a circle.</p></section>
<section id="unwrap" data-step data-start="circle" data-duration="8"><p>Its height draws a wave.</p></section>
Step k's range runs to step k + 1's start, and the last step's to its own end or the composition's. Steps are ordered by start, then by document order, so of two that start together the later holds the time and the earlier has no range (KN-118 in development).
Kinetic keeps the step table between seeks and rebuilds it in these cases.
- The composition's duration or root changes, or a footage file's length moves the timing.
- Readiness runs, or
step()is called. - On a page with steps or step definitions, a step comes or goes under the root, an element a relative start refers to comes or goes, or a timing attribute changes (
data-start,data-duration,data-end,data-media-start,data-playback-rate, anid). A page with neither does no step work on its DOM changes.
step(id, definition)
wildflower.kinetic.step(id, { wait, until, entry, script }) gives the step with that id what markup cannot carry.
Each option is checked when step() is called, and a bad one throws (KN-100).
wildflower.kinetic.step('ask', {
wait: 1, // holds 1 s into the step
until: 'sine.answered', // once truthy, plays the rest of the step and holds for Next
entry: { 'sine.choice': null }, // written as interactive mode enters the step
script: { 'sine.choice': { at: [0, 2], set: [null, 270] } } // written on every seek in video mode
});
| Option | Interactive mode | Video mode |
|---|---|---|
wait | Seconds from the step's start at which it holds, by default the shorter of its range and its clip's duration, so the hold never shows the clip hidden.
A wait past the range is clamped to it, and a hold past the composition's end is on its last frame.
false plays the step and goes on. | ignored |
until | A store path, such as 'quiz.answered', read as data-model reads one.
The step holds until the path is truthy (at once if it already is), then plays the rest of its range from the hold and holds on its last frame for Next, in a task of Kinetic's own, after the handler that wrote the answer and its flush have finished.
So what comes after the hold, such as the explanation of a right answer, plays once the answer is right.
A step that holds at the end of its range stays where it is.
canAdvance and next() wait for the path too. | ignored |
entry | { path: value }, written before the clock moves when next(), back(), go(), autoplay, or the previous step's wait: false enters the step.
Written again when play() at or past the hold restarts the step, or a seek of the learner's own (seek(), the bar's scrub or frame step) lands before the hold.
A seek at or past the hold, the playing clock's frames, and pause and play keep the learner's values. | ignored |
script | not applied | { path: entry }, written on every seek (Scripts). |
Interactive mode enters a step with play({ from: start, to: start + wait }), so the clock holds on the last frame before the wait point.
- A step that holds on its own first frame (
wait: 0) is entered with a seek of its own. - A step with
wait: falseand the steps after it, up to the first that holds, play as one range from the first one's start. Each later step is entered on its own first frame, with itsentrywritten in that frame. - While a step holds the clock is paused, so motion driven by the time stands still, and bindings and followers go on.
- Once a step's
untilpath is truthy at its hold, the clock plays from the hold to the end of the step's range (the play-out) withwaitingfalse. At the play-out's last frame the step holds again, withwaitingtrue, for Next.back(),go()and a seek while it plays do what they do anywhere in the step.
Interactive mode never moves to the next step by itself: the learner does, with Next.
canAdvance, which a lesson's Next button binds to, follows the step's until path, and is true when the step has none.
It is the same while the step plays in or out as at its hold, so Next is ready as soon as nothing is left for the learner to do.
Pressed anywhere in the step, Next enters the next step at once, and the rest of the step does not play: what follows a hold with no until belongs to the video, where the step's script writes.
Back is never disabled.
<!-- In a component whose next() and back() call wildflower.kinetic's -->
<button type="button" data-action="back">Back</button>
<button type="button" data-action="next" data-bind-attr="{ disabled: !$kinetic.canAdvance }">Next</button>
wait: false with until throws (KN-100), since there is no hold for until to wait at.
next(), back(), go(step) and play()
wildflower.kinetic.next(); // when canAdvance is true, the rest of the step or the next step; otherwise nothing
wildflower.kinetic.back();
wildflower.kinetic.go('ask'); // by id, or go(0) by index
| Call | Interactive mode | Video mode |
|---|---|---|
next() | Nothing while canAdvance is false. Otherwise enters the next step at once with its entry, wherever the step is, the rest of it unplayed. Nothing on the last step or past it. | Seeks to the next step's start. |
back() | Enters the previous step and plays it from its start, with its entry; past the last step's end, the last step. Nothing at the first step. | Seeks to the previous step's start. |
go(step) | Enters a step by id or by index from 0. A step that is not there throws (KN-100). | Seeks to the step's start. |
play() | Plays on to the step's hold, and at or past the hold plays the step again from its start, with its entry. Past the last step's end it plays on to the composition's end and holds there, and at the end it plays the last step again. | Plays on. |
Each of the four calls reads the mode first, so a runtime that loaded after Kinetic puts them in video mode before the next seek does.
pause(), seek(t) and play({ from, to }) work as they do without steps.
- In video mode
next(),back()andgo()keep a playing clock playing with the hold it had, so a video started withplay({ from: 0 })still stops at its end. - Back from video mode, interactive mode keeps the step it had entered only if the clock is inside it (as
check()andrender()leave it), and otherwise takes the step at the clock's time. - With
autoplayon, an interactive page with steps enters the first step when the registered run of readiness resolves (after fonts, images and queries), and never if that run fails. - With
autoplayoff, the page shows the first frame of the first step untilplay()orgo(0)starts it.
The step fields
| Field | Value |
|---|---|
step | The id of the step whose range holds time, in both modes. '' before the first step, past the last step's end, or with no steps. |
stepIndex | Its index from 0, or -1. |
stepCount | How many steps the composition has. |
stepStart, stepLength | Its range, in composition seconds. |
waiting | True while interactive mode holds at the step's wait point, from the moment the hold's seek has settled, and on the last frame of a step after its play-out. False in video mode. |
canAdvance | What a Next button binds to. In interactive mode, false while the step's until path is falsy, and true otherwise, the same while the step plays in or out as at its hold. Always true in video mode. |
step, stepIndex, stepStart and stepLength are written in the seek's batch with time, so seek(local) and the seek's first flush see the new time's step.
In interactive mode, document gets a kinetic:step CustomEvent with detail: { step, index } once the entering seek has settled.
document.addEventListener('kinetic:step', (e) => {
// e.detail.step is the step's id, and e.detail.index its index from 0
});
- For a later step of a
wait: falserun, it follows once the seek that reached the step has landed. - It comes outside the window in which development builds watch for timers and
fetch(). - A watcher on
store:kinetic.stepruns inside the seek, and in video mode on every seek that crosses a step, so interactive-only entry work goes in the event or inentry.
Scripts
A step's script maps store paths to entries, with times in seconds from the step's start.
wildflower.kinetic.step('circle', {
script: {
'sine.angle': { at: [2, 5.5], to: [30, 120], ease: 'power2.inOut' }, // a track
'sine.choice': { at: [0, 2], set: [null, 270] } // settings
}
});
| Entry | For | Reading |
|---|---|---|
A track: { at: [0, 6], to: [0, 360], ease, extrapolate, round, space } | Numbers, colors, and text with numbers in it. | interpolate(), as a keyframes track reads. A malformed track or a bad ease throws at step() (KN-120, KN-121). |
Settings: { at: [0, 2], set: [null, 270] } | A string, a number, a boolean or null. Anything else throws (KN-100), since an object written to a store reads back as another object. | Each value holds from its time to the next. |
In video mode a path's value at time t comes from the scripts alone, never the store, so a seek to any frame shows what playing through shows, whatever was sought before or the learner did.
- The value comes from the latest step that starts at or before t and scripts the path, at t minus that step's start, and a track holds its last value after its last keyframe.
- Before a settings entry's first time, the previous step that scripts the path gives the value by the same rule.
- Before the first step that scripts the path, the value is that entry's first value (a track's first keyframe, or the settings' first value).
- Times are compared with the clips' tolerance of a millionth of a second, so a setting at
59 / 30applies on frame 59. - A path no script touches keeps its current value through the video.
How script values are written
Each scripted path is written in the seek's batch with the time and step fields, through the store's own update(), only when its value differs.
- In video mode any other write to a scripted path (a slider dragged during a render, a watcher) is put back to the script's value within a microtask, before the next paint, and development builds warn (KN-146).
A slider outside the stage can be disabled in video mode with
data-bind-attr="{ disabled: $kinetic.mode === 'video' }". - A store's or component's watcher runs inside the write that set it off, so its write to a scripted path during the script's write happens before Kinetic can tell the two apart. Kinetic compares each scripted path with the value it last wrote there before its writes, and reads them all back after.
- A path is put back at most once between two seeks, so a watcher that rewrites it every time keeps its value after that correction (with KN-146 printed), and the two never alternate on every frame.
- A scripted path whose store does not exist is skipped.
That path, or one that names no state field (a computed among them), fails each run of readiness (KN-100), so
ready()rejects andcheck()andrender()fail with it.
Script values are real store writes, so watchers, subscribe() callbacks and autoSave see them during check(), render() and a switch to video mode.
Interactive-only effects go in action handlers, entry or the kinetic:step event.
A store holding scripted paths uses no autoSave, so saved lesson progress goes in a store of its own.
follow
A scene's follow block eases values toward store paths by a spring, and each entry becomes a computed of the same name.
A value derived from a control reads the followed value, as point() does below.
wildflower.kinetic.scene('sine-circle', {
subscribe: ['sine'],
follow: {
angle: 'sine.angle', // { spring: { duration: 0.4 } }
tilt: { target: 'sine.tilt', ease: { spring: { duration: 0.6, bounce: 0.2 } } }
},
computed: { point() { return at(this.angle); } }
});
| Field | Value |
|---|---|
target | A store path. A string entry is the target alone. |
ease | A spring: 'spring', { spring: { stiffness, damping, mass } } or { spring: { duration, bounce } }. { spring: { duration: 0.4 } } by default, which is critically damped and never overshoots. |
In interactive mode the follower's value is its value before the first change plus each change of the target times the spring's step response at the time since that change, on the page's clock. So a target that moves while the follower is moving carries the velocity on.
- A change folds into that first value once the spring is within 1e-4 of rest.
- While something moves, Kinetic runs its own
requestAnimationFrameloop, and while the clock plays, the clock's frame moves the followers instead.
In video mode the follower applies the same spring to the target's script, sampled on the frames of the root's data-fps (or of config({ fps }) for a root with none) over the fold time before the frame.
Its value is then a function of composition time, so a seek in any order gives the same value and check() passes.
- A target no step scripts is passed through in video mode.
- Video mode freezes the interactive history and interactive mode resumes it on the page's clock, so a follower settled before a render is still settled on the learner's value after it.
Kinetic checks the block's eases, its targets' values and its entry names.
- An ease that is no spring,
overshootClamping, or a spring with no damping throws when the scene is defined (KN-100), since none gives one spring's response through a change of target. - The target's value is a finite number, or
trueandfalseas 1 and 0, and anything else throws when the follower reads it (KN-100). - Development builds warn about an entry named like a computed Kinetic provides (KN-152), and about one named like a
computedorkeyframesentry of the same scene, where the scene reads the follow entry (KN-153).
Diagnostics in interactive mode
In interactive mode on a page with steps, two determinism warnings are off.
- KN-132 (a
waitForquery reloaded after frame 0), since a lesson refreshes its queries. - KN-143 (a CSS transition under the root), since the learner starts hover and press transitions.
check() and render() run in video mode, where both are on, and the other determinism warnings stay on in both modes, since a render runs the same seek code.
Development builds warn with KN-118 when the step markup has one of these faults.
- A
data-stepelement has noidor nodata-start. - Two steps have one
id, or a step is inside another step. - Two steps start together, which leaves the first with no range.
- The first step starts after 0.
- A step starts at or past the composition's end, which
go()holds on the last frame.
They warn with KN-157 in these cases.
step()is called for an id nodata-stepelement has.- An
untilorfollowtarget's store does not exist at readiness. - A
waitis longer than the step's range. - A settings entry starts after 0 in the first step that scripts its path.
The sine lesson demo is a four-step lesson with a question and a last step that waits for the learner to drag, and plays as a video with the angle and the answer scripted.
A finished lesson
- It has no preview bar, and loads the minified builds in place of the development ones it was made with.
- Its last step offers a "Start again" button that calls
go(0), so the learner never steps back through every step. - Its controls and quiz buttons are disabled in video mode, where the scripts write the same store paths:
data-bind-attr="{ disabled: $kinetic.mode === 'video' }", or a row field set fromthis.stores.kinetic.modefor buttons in adata-list.
A lesson's layout
Every lesson the skill writes uses one layout, so one lesson is consistent with the next.
Everything fits in the 16:9 frame.
The left column holds the step's heading, a line or two of body, the question's choices, and the current step's own controls, and the illustration fills the rest.
A band along the bottom holds the step count, the transport, and Back and Next.
The band and the step's controls show in interactive mode alone, so the video has the same layout with their space left empty.
A Lesson's Layout is the layout in a two-step lesson, and the skill's patterns.md has its code.
Lesson patterns
Each example below calls config({ mode: 'interactive' }), so it runs as a lesson with the preview bar up, which a finished lesson drops.
Choose an answer, or press a button on the stage, and the step goes on from there.
A question that explains a wrong answer
The step holds with the question up, before the explanation of the right answer.
A wrong pick shows why it is wrong and leaves the step holding, so the learner can try again.
The right pick turns until true, so the rest of the step plays, the explanation with it, and the step holds on its last frame for Next.
waitis earlier than the step's end, and the explanation is a clip that starts after it.- The wrong pick's text is a binding on the store, so it shows during the hold, while the clock stands still.
- In the video the script picks the right answer before the hold's time, and the explanation plays as it does on the page, so the buttons are disabled in video mode.
- Next, shown in the lesson alone, follows
canAdvance: off until the right pick, then ready while the explanation plays and once the step holds at its end. - The last step offers "Start again", shown in the lesson alone.
<div id="stage" data-composition-id="planets" data-start="0" data-duration="9" data-width="1280" data-height="720" data-fps="30">
<section id="ask" class="layer cap" data-step data-start="0" data-duration="6">
<h2>Which planet is closest to the Sun?</h2>
</section>
<div class="layer quiz" data-component="quiz" data-start="0" data-duration="6">
<div class="choices" data-list="choices" data-key="id">
<template><button type="button" data-action="choose" data-bind="label" data-bind-class="cls" data-bind-attr="{ disabled: off }"></button></template>
</div>
<p class="why" data-show="why" data-bind="why"></p>
<button type="button" class="next" data-action="next" data-show="$kinetic.mode === 'interactive'" data-bind-attr="{ disabled: !$kinetic.canAdvance }">Next</button>
</div>
<p class="layer reveal" data-start="2" data-duration="4">Right: Mercury orbits 58 million km from the Sun.</p>
<section id="next" class="layer cap" data-step data-start="ask" data-duration="3">
<h2>Next: why Venus is hotter.</h2>
<div class="again" data-component="again"><button type="button" data-action="again" data-show="$kinetic.mode === 'interactive'">Start again</button></div>
</section>
</div>
const { step } = wildflower.kinetic;
wildflower.kinetic.config({ mode: 'interactive' }); // the lesson, with the preview bar kept
const CHOICES = [
{ id: 'venus', label: 'Venus', why: 'Not quite: Venus is the second planet out. Try another.' },
{ id: 'mercury', label: 'Mercury', why: '' },
{ id: 'earth', label: 'Earth', why: 'Not quite: Earth is the third planet out. Try another.' },
];
wildflower.store('quiz', {
state: { pick: null },
computed: { right() { return this.pick === 'mercury'; } },
});
step('ask', {
wait: 2, // holds before the explanation, which starts at 2 s
until: 'quiz.right', // the right pick plays the rest of the step, which then holds
entry: { 'quiz.pick': null }, // cleared on the way in
script: { 'quiz.pick': { at: [0, 1], set: [null, 'mercury'] } }, // the video picks the right answer at 1 s
});
wildflower.component('quiz', {
subscribe: ['quiz', 'kinetic'],
computed: {
choices() {
const p = this.stores.quiz.pick, off = this.stores.kinetic.mode === 'video'; // the video's script picks
return CHOICES.map((c) => ({ id: c.id, label: c.label, off, cls: p !== c.id ? '' : c.id === 'mercury' ? 'right' : 'wrong' }));
},
why() { const c = CHOICES.find((c) => c.id === this.stores.quiz.pick); return c ? c.why : ''; },
},
choose(event, element, details) { this.stores.quiz.pick = details.item.id; },
next() { wildflower.kinetic.next(); }, // the lesson waits on its end for this
});
// The last step: back to the first, in the lesson alone.
wildflower.component('again', { subscribe: ['kinetic'], again() { wildflower.kinetic.go(0); } });
html, body { margin: 0; background: #0a0b10; }
#stage { position: relative; overflow: hidden; background: #0b0f1a; color: #f4f1ea; font: 500 40px/1.3 system-ui, sans-serif; }
.layer { position: absolute; inset: 0; }
.cap h2 { margin: 120px 0 0; text-align: center; font-size: 64px; }
.choices { position: absolute; left: 0; right: 0; top: 330px; display: flex; justify-content: center; gap: 32px; }
.choices button { font: inherit; color: inherit; background: #1b2233; border: 3px solid #3a4560; border-radius: 16px; padding: 18px 40px; cursor: pointer; }
.choices button.right { background: #17473a; border-color: #5fd3c6; }
.choices button.wrong { background: #4a1d2a; border-color: #ff7b8f; }
.why { position: absolute; left: 0; right: 0; top: 500px; margin: 0; text-align: center; color: #ffb3bf; }
.reveal { top: 500px; bottom: auto; margin: 0; text-align: center; color: #5fd3c6; }
.again { position: absolute; left: 0; right: 0; top: 400px; text-align: center; }
.next { position: absolute; right: 64px; bottom: 64px; }
.next:disabled { opacity: 0.4; cursor: default; }
.again button, .next { font: inherit; color: inherit; background: #1b2233; border: 3px solid #3a4560; border-radius: 16px; padding: 14px 36px; cursor: pointer; }
[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
Content for the lesson alone
A hint or a "try it" prompt belongs to the lesson and has no place in the video.
The store's mode tells the two apart: a binding reads $kinetic.mode, and a computed reads this.stores.kinetic.mode in a scene or component that subscribes to kinetic.
check() and render() run in video mode, so the frames they see and the file a render makes have none of it.
<div id="stage" data-composition-id="lab" data-start="0" data-duration="5" data-width="1280" data-height="720" data-fps="30">
<section id="grow" class="layer" data-step data-start="0" data-duration="5"></section>
<div class="layer" data-component="lab" data-start="0" data-duration="5">
<svg viewBox="0 0 1280 720" width="1280" height="720"><circle cx="640" cy="330" fill="#5fd3c6" data-bind-attr="dot"></circle></svg>
<div class="try" data-show="$kinetic.mode === 'interactive'">
<p>Try it: double the radius.</p>
<button type="button" data-action="small">Radius 60</button>
<button type="button" data-action="large">Radius 120</button>
</div>
<p class="hint" data-show="hint" data-bind="hint"></p>
</div>
</div>
const { scene, step } = wildflower.kinetic;
wildflower.kinetic.config({ mode: 'interactive' }); // the lesson, with the preview bar kept
wildflower.store('lab', { state: { r: 60 } });
// The lesson holds at 1 s for the learner; the video doubles the radius itself.
step('grow', { wait: 1, script: { 'lab.r': { at: [1, 3], to: [60, 120], ease: 'power2.inOut' } } });
scene('lab', {
subscribe: ['lab', 'kinetic'],
follow: { r: 'lab.r' },
computed: {
dot() { return { r: this.r.toFixed(1) }; },
// The lesson alone: a hint once the learner has doubled the radius.
hint() {
const lesson = this.stores.kinetic.mode === 'interactive';
return lesson && this.stores.lab.r === 120 ? 'Twice the radius gives four times the area.' : '';
},
},
small() { this.stores.lab.r = 60; },
large() { this.stores.lab.r = 120; },
});
html, body { margin: 0; background: #0a0b10; }
#stage { position: relative; overflow: hidden; background: #0b0f1a; color: #f4f1ea; font: 500 36px/1.3 system-ui, sans-serif; }
.layer { position: absolute; inset: 0; }
.try { position: absolute; left: 0; right: 0; top: 520px; text-align: center; }
.try p { margin: 0 0 16px; }
.try button { font: inherit; color: inherit; background: #1b2233; border: 3px solid #3a4560; border-radius: 14px; padding: 10px 28px; margin: 0 12px; cursor: pointer; }
.hint { position: absolute; left: 0; right: 0; top: 40px; margin: 0; text-align: center; color: #ffd27a; }
[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
Checking a lesson
The skill's check script walks a lesson as a learner would: node check.cjs index.html --lesson.
After its check() it enters each step in turn with go() in interactive mode, waits for the step's hold, the last frame before its wait, and saves a still of the stage at the hold.
For each step it reports whether the step held, the store path of the until it waits on, and the console errors and KN lines printed during it.
It answers no question: a step that waits on its until is reported, and the walk goes on to the next step.
A step that never holds, a console error, or a KN line fails the run.