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() and render() 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'.

SituationMode
A renderer's runtime on the page, or window.__HF_RENDER_CAPTURE_MODE setvideo, whatever the setting
While check() or render() runsvideo, then back
'video', or 'auto' with the preview bar upvideo
'interactive', or 'auto' on a plain pageinteractive

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.

  1. They seek back to the time they found, still in video mode.
  2. They write the learner's values back.
  3. 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, an id). 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
});
OptionInteractive modeVideo mode
waitSeconds 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
untilA 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
scriptnot 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: false and 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 its entry written 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 until path is truthy at its hold, the clock plays from the hold to the end of the step's range (the play-out) with waiting false. At the play-out's last frame the step holds again, with waiting true, 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
CallInteractive modeVideo 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() and go() keep a playing clock playing with the hold it had, so a video started with play({ 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() and render() leave it), and otherwise takes the step at the clock's time.
  • With autoplay on, 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 autoplay off, the page shows the first frame of the first step until play() or go(0) starts it.

The step fields

FieldValue
stepThe 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.
stepIndexIts index from 0, or -1.
stepCountHow many steps the composition has.
stepStart, stepLengthIts range, in composition seconds.
waitingTrue 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.
canAdvanceWhat 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: false run, 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.step runs inside the seek, and in video mode on every seek that crosses a step, so interactive-only entry work goes in the event or in entry.

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
    }
});
EntryForReading
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 / 30 applies 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 and check() and render() 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); } }
});
FieldValue
targetA store path. A string entry is the target alone.
easeA 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 requestAnimationFrame loop, 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 true and false as 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 computed or keyframes entry 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 waitFor query 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-step element has no id or no data-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 no data-step element has.
  • An until or follow target's store does not exist at readiness.
  • A wait is 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 from this.stores.kinetic.mode for buttons in a data-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.

  • wait is 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; }
A question that explains a wrong answer

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; }
Content for the lesson alone

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.