A Lesson's Layout

The code of the A Lesson's Layout demo, 2 files.

Run the demo All demos

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(); },
});