A Lesson's Layout
The code of the A Lesson's Layout demo, 2 files.
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>A Lesson's Layout | WildflowerJS Demo</title>
<script defer src="/js/analytics.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,100..900;1,100..900&display=block" rel="stylesheet">
<!-- The framework, then Kinetic, then the lesson. With no HyperFrames
runtime on the page, Kinetic runs the clock itself, and the page is
a lesson in interactive mode. With ?preview in the URL the preview bar
comes up, and the page is the lesson's video. -->
<script defer src="/js/dist/wildflower.lite.min.js"></script>
<script defer src="/js/extensions/kinetic.wf.min.js"></script>
<script defer src="lesson.js"></script>
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #e4dbca; color: #26282d; font-family: 'Work Sans', sans-serif; }
.demo-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 8px 20px; background: #efe8db; border-bottom: 1px solid #ddd3c2; }
.demo-toolbar a { display: flex; align-items: center; gap: 10px; text-decoration: none; color: #26282d; font-size: 1rem; }
.demo-toolbar .logo { height: 32px; }
.demo-toolbar .brand-wf { font-weight: 520; }
.demo-toolbar .brand-js { font-weight: 700; }
.demo-toolbar .demo-info { font-size: 12px; color: #6e685f; text-align: right; }
.demo-toolbar .demo-info strong { color: #26282d; }
/* The page: the stage as wide as the window, and no taller than the room under the toolbar (49 px on one line). */
.lesson { position: relative; width: min(100%, calc((100vh - 49px) * 16 / 9)); margin: 0 auto; }
/* On a phone held upright, a note over the frame, outside the root, so no check or render sees it. */
.turn { display: none; }
@media (orientation: portrait) and (max-width: 600px) {
.turn { display: block; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); margin: 0; padding: 10px 16px; border-radius: 10px; background: #26282d; color: #efe8db; font-size: 16px; font-weight: 600; white-space: nowrap; }
}
#stage { position: relative; overflow: hidden; background: #efe8db; }
.layer { position: absolute; inset: 0; pointer-events: none; }
/* Hidden until Kinetic marks the root ready. */
[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
/* The left column, from the safe area's top left: the heading, the body, the prompt, the choices or the step's control, the note. */
.cap h2 { position: absolute; left: 64px; top: 64px; width: 560px; margin: 0; font-size: 44px; font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.cap p { position: absolute; left: 64px; top: 120px; width: 500px; margin: 0; font-size: 22px; font-weight: 500; line-height: 1.3; }
.prompt { position: absolute; left: 64px; top: 196px; margin: 0; padding: 6px 12px; border-radius: 8px; background: #EFC187; font-size: 18px; font-weight: 600; }
.choices { position: absolute; left: 64px; top: 252px; display: flex; flex-direction: column; gap: 8px; width: 220px; }
.choices button { pointer-events: auto; padding: 10px 18px; border: 0; border-radius: 10px; background: #e4dbca; color: #26282d; font: 600 22px/1.2 'Work Sans', sans-serif; text-align: left; cursor: pointer; }
.choices button:disabled { cursor: default; }
.choices button.right { background: #B1A8FF; }
.choices button.wrong { background: #EF9481; }
.cap p.note, .note { position: absolute; left: 64px; top: 430px; width: 460px; margin: 0; font-size: 22px; font-weight: 600; line-height: 1.3; }
.ctl { position: absolute; left: 64px; top: 252px; width: 360px; display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 12px; pointer-events: auto; font-size: 20px; font-weight: 600; }
.ctl b { justify-self: start; padding: 2px 10px; border-radius: 8px; background: #B1A8FF; font-variant-numeric: tabular-nums; }
.ctl input { grid-column: 1 / -1; width: 100%; }
/* The band along the bottom of the safe area: where the lesson is, the step's transport, Back and Next. */
.band { position: absolute; left: 64px; right: 64px; top: 600px; height: 56px; display: flex; align-items: center; gap: 32px; pointer-events: auto; }
.where { width: 240px; }
.count { font-size: 15px; font-weight: 600; color: #6e685f; }
.title { margin-top: 2px; font-size: 22px; font-weight: 800; letter-spacing: -0.02em; white-space: nowrap; }
.transport { flex: 1; display: flex; align-items: center; gap: 8px; }
.nav { display: flex; gap: 8px; }
.band button { padding: 10px 18px; border: 0; border-radius: 10px; background: #e4dbca; color: #26282d; font: 600 18px/1.2 'Work Sans', sans-serif; white-space: nowrap; cursor: pointer; }
.band button.play { width: 96px; } /* Play and Pause, one width */
/* Next lights up in the accent once the learner can use it; until then it sits quiet. */
.band button.go { background: #B1A8FF; }
.band button:disabled { background: #e4dbca; color: #6e685f; cursor: default; }
input[type="range"] { -webkit-appearance: none; appearance: none; height: 24px; margin: 0; background: transparent; cursor: pointer; }
input[type="range"]::-webkit-slider-runnable-track { height: 6px; border-radius: 3px; background: #ddd3c2; }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; margin-top: -7px; border-radius: 50%; background: #26282d; }
input[type="range"]::-moz-range-track { height: 6px; border-radius: 3px; background: #ddd3c2; }
input[type="range"]::-moz-range-thumb { width: 20px; height: 20px; border: 0; border-radius: 50%; background: #26282d; }
</style>
</head>
<body>
<div class="demo-toolbar">
<a href="/demos/">
<img class="logo" src="/assets/wildflower_96.png" alt="WildflowerJS">
<span><span class="brand-wf">Wildflower</span><span class="brand-js">JS</span> Demos</span>
</a>
<div class="demo-info"><strong>A Lesson's Layout</strong>: a lesson in two steps</div>
</div>
<main class="lesson">
<div id="stage" data-composition-id="area" data-start="0" data-duration="10" data-width="1280" data-height="720" data-fps="30">
<!-- The illustration, in the frame above the band. -->
<div class="layer" data-component="disc" data-start="0" data-duration="10">
<svg viewBox="0 0 1280 720" width="1280" height="720" aria-hidden="true">
<circle cx="900" cy="320" fill="#B1A8FF" data-bind-attr="disc"></circle>
<circle cx="900" cy="320" r="80" fill="#90CDEF"></circle>
</svg>
</div>
<section id="grow" class="layer cap" data-step data-start="0" data-duration="5">
<h2>A circle's area</h2>
<p>The blue disc has a radius of 80. The lavender one grows around it.</p>
</section>
<section id="ask" class="layer cap" data-step data-start="grow" data-duration="5">
<h2>Twice the radius</h2>
<p>When the radius doubles, how many times the area does the circle have?</p>
</section>
<div class="layer" data-component="quiz" data-start="grow" data-duration="5">
<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="note" data-show="why" data-bind="why"></p>
</div>
<!-- The explanation, after the question's hold at 6 s: it plays once the answer is right. -->
<div class="layer cap" data-start="7" data-duration="3"><p class="note">Four times: the disc is twice as wide and twice as tall.</p></div>
<div class="layer" data-component="prompt" data-start="0" data-duration="10">
<p class="prompt" data-show="text" data-bind="text"></p>
</div>
<!-- The step's own control and the band: inside the root, shown in interactive mode alone,
so the video keeps their space as empty paper. -->
<div class="layer" data-component="lesson-controls" data-start="0" data-duration="10">
<label class="ctl" data-show="growing">
<span>Radius</span><b data-bind="radiusText"></b>
<input type="range" min="80" max="200" step="1" data-model="area.r" data-model-number>
</label>
<div class="band" data-show="$kinetic.mode === 'interactive'">
<div class="where"><div class="count" data-bind="countText"></div><div class="title" data-bind="title"></div></div>
<div class="transport">
<button type="button" class="play" data-action="toggle" data-bind="playLabel"></button>
<button type="button" data-action="replay">Replay step</button>
</div>
<div class="nav">
<button type="button" data-action="back" data-bind-attr="backAttrs">Back</button>
<button type="button" class="go" data-action="next" data-show="more" data-bind-attr="nextAttrs">Next</button>
<button type="button" class="go" data-action="again" data-show="last">Start again</button>
</div>
</div>
</div>
</div>
<p class="turn">Turn your phone sideways</p>
</main>
</body>
</html>
lesson.js
// A lesson's layout: the one layout every lesson from Kinetic's skill uses
// (the skill's patterns.md, "A lesson's layout"). Everything is inside the
// 1280 by 720 composition: the step's heading, body, choices and own control
// in the left column, the illustration in the rest of the frame, and the band
// along the bottom with the step count, Play and Replay, Back and Next. The
// control and the band show in interactive mode alone, so the video keeps
// their space as empty paper. On a phone held upright the page shows "Turn
// your phone sideways" over the frame, outside the root (index.html's CSS).
const { scene, step } = wildflower.kinetic;
wildflower.kinetic.config({ shutter: 180, fit: true, finish: { grain: 0.08 } }); // motion blur in a render, the stage fitted to the page, and the paper's still grain
const TITLES = { grow: 'Growing the radius', ask: 'Twice the radius' };
const CHOICES = [
{ id: '2', label: '2×', why: 'Not quite: the disc is twice as wide, and twice as tall too. Try another.' },
{ id: '4', label: '4×', why: '' },
{ id: '8', label: '8×', why: 'Not quite: 8× is for a ball, which doubles in depth as well. Try another.' },
];
wildflower.store('area', { state: { r: 80, pick: null }, computed: { right() { return this.pick === '4'; } } });
step('grow', { wait: 1, entry: { 'area.r': 80 }, script: { 'area.r': { at: [1, 4], to: [80, 160], ease: 'power2.inOut' } } });
step('ask', {
wait: 1, // holds before the explanation at 2 s
until: 'area.right', // a wrong pick keeps it waiting; the right one plays the explanation, Next lit
entry: { 'area.pick': null }, // cleared on the way in
script: { 'area.pick': { at: [0, 1.5], set: [null, '4'] } }, // the video picks the right answer at 1.5 s
});
scene('disc', { subscribe: ['area'], follow: { r: 'area.r' }, computed: { disc() { return { r: this.r.toFixed(1) }; } } });
wildflower.component('quiz', {
subscribe: ['area', 'kinetic'],
computed: {
choices() {
const p = this.stores.area.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 === '4' ? 'right' : 'wrong' }));
},
why() { const c = CHOICES.find((c) => c.id === this.stores.area.pick); return c ? c.why : ''; },
},
choose(event, element, details) { this.stores.area.pick = details.item.id; },
});
// Prompts to the learner, in the lesson alone.
wildflower.component('prompt', {
subscribe: ['kinetic'],
computed: {
text() {
const k = this.stores.kinetic;
if (k.mode !== 'interactive') return '';
return { grow: 'Your turn: double the radius with the slider.', ask: 'Your turn: pick an answer.' }[k.step] || '';
},
},
});
// The step's own control and the band, both in the frame and in the lesson alone.
wildflower.component('lesson-controls', {
subscribe: ['area', 'kinetic'],
computed: {
countText() { const k = this.stores.kinetic; return k.stepCount ? 'Step ' + (k.stepIndex + 1) + ' of ' + k.stepCount : ''; },
title() { return TITLES[this.stores.kinetic.step] || ''; },
backAttrs() { return { disabled: this.stores.kinetic.stepIndex <= 0 }; },
nextAttrs() { return { disabled: !this.stores.kinetic.canAdvance }; }, // off while until is false: a question not yet answered
last() { const k = this.stores.kinetic; return k.stepCount > 0 && k.stepIndex === k.stepCount - 1; },
more() { return !this.last; },
playLabel() { return this.stores.kinetic.playing ? 'Pause' : 'Play'; },
// The radius slider belongs to the first step, in the lesson alone.
growing() { const k = this.stores.kinetic; return k.mode === 'interactive' && k.step === 'grow'; },
radiusText() { return String(Math.round(this.stores.area.r)); },
},
back() { wildflower.kinetic.back(); },
next() { wildflower.kinetic.next(); },
again() { wildflower.kinetic.go(0); },
replay() { wildflower.kinetic.go(this.stores.kinetic.stepIndex); },
toggle() { if (this.stores.kinetic.playing) wildflower.kinetic.pause(); else wildflower.kinetic.play(); },
});