Primary

The code of the Primary 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>Primary | WildflowerJS Demo</title>
    <script defer src="/js/analytics.js"></script>
    <!-- The framework loads, then Kinetic with its preview bar, then the
         scenes. Kinetic runs the clock, the transitions and the render. -->
    <script defer src="/js/dist/wildflower.lite.min.js"></script>
    <script defer src="/js/extensions/kinetic.wf.min.js" data-preview></script>
    <script defer src="app.js"></script>
    <style>
        * { box-sizing: border-box; }
        html, body { margin: 0; background: #0a0b10; color: #e6e6f0; font-family: system-ui, sans-serif; }
        .demo-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 20px; background: #12141c; border-bottom: 1px solid rgba(255,255,255,0.08); }
        .demo-toolbar a { display: flex; align-items: center; gap: 10px; text-decoration: none; color: #5fa6a6; 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: 11px; color: rgba(255,255,255,0.6); text-align: right; }
        .demo-toolbar .demo-info strong { color: rgba(255,255,255,0.9); }
        .frame { padding: 24px; }

        /* Hidden until Kinetic marks the root ready, since before that the
           stage holds unbound markup and every clip at once. */
        #stage:not([data-kinetic-ready]) { visibility: hidden; }

        #stage {
            --paper: #f2ede3; --ink: #16161d; --red: #ff4632; --blue: #2a4bff; --yellow: #ffc81e; --stone: #b9b1a2;
            --grotesk: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            --serif: Didot, "Bodoni 72", "Bodoni MT", Georgia, "Times New Roman", serif;
            --mono: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
            position: relative; width: 1280px; height: 720px; overflow: hidden; background: var(--ink);
            font-family: var(--grotesk);
            /* The flip between the data and the beat reads as depth only
               with a perspective on the clips' common parent. */
            perspective: 1800px;
        }
        .layer { position: absolute; inset: 0; overflow: hidden; }
        .ch { display: inline-block; white-space: pre; }
        svg.layer { overflow: visible; }

        /* 01 Shape */
        .s1 { background: var(--paper); }
        .s1 .hair { fill: none; stroke: rgba(22,22,29,0.22); stroke-width: 1.5; stroke-dasharray: 1; }
        .s1 .outline { fill: none; stroke: var(--ink); stroke-width: 2; stroke-dasharray: 1; stroke-linecap: round; stroke-linejoin: round; }
        .s1 .roll { position: absolute; left: 0; right: 0; top: 612px; height: 16px; overflow: hidden; text-align: center; font: 600 13px/16px var(--mono); letter-spacing: 0.24em; color: var(--ink); }
        .s1 .roll span { position: absolute; left: 0; right: 0; }

        /* 02 Line */
        .s2 { background: var(--ink); color: var(--paper); }
        .s2 .dots { background-image: radial-gradient(circle, rgba(242,237,227,0.16) 1.2px, transparent 1.6px); background-size: 28px 28px; }
        .s2 .cam { transform-origin: 1160px 220px; }
        .s2 .ghost { fill: none; stroke: rgba(242,237,227,0.22); stroke-width: 2; stroke-dasharray: 2 9; stroke-linecap: round; }
        .s2 .route { fill: none; stroke: var(--paper); stroke-width: 5; stroke-dasharray: 1; stroke-linecap: round; stroke-linejoin: round; }
        .s2 .box { fill: none; stroke: var(--yellow); stroke-width: 4; stroke-dasharray: 1; stroke-linecap: square; }
        .s2 .arrow { position: absolute; left: 0; top: 0; width: 64px; height: 32px; offset-rotate: auto; }
        .s2 .arrow svg { display: block; overflow: visible; }
        .s2 .arrow path { fill: var(--red); }
        .s2 .station { position: absolute; left: 0; top: 0; width: 0; height: 0; offset-rotate: 0deg; }
        .s2 .station .dot { position: absolute; left: -11px; top: -11px; width: 22px; height: 22px; border-radius: 50%; background: var(--ink); border: 4px solid var(--yellow); }
        .s2 .station .ring { position: absolute; left: -11px; top: -11px; width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--yellow); }
        .s2 .station .tag { position: absolute; left: 22px; top: 14px; min-height: 1em; white-space: pre; font: 600 15px/1 var(--mono); letter-spacing: 0.12em; }
        .s2 .station .tag b { display: block; margin-bottom: 6px; font-weight: 600; font-size: 11px; color: var(--yellow); }
        .s2 .headline { position: absolute; left: 96px; top: 92px; margin: 0; padding-bottom: 0.2em; font: italic 400 50px/1.1 var(--serif); }

        /* 03 Type */
        .s3 { color: var(--paper); }
        .s3 .block { position: absolute; inset: 0; }
        .s3 .mask { position: absolute; left: 92px; overflow: hidden; padding: 0 0.04em; }
        .s3 .big { display: flex; font-size: 168px; font-weight: 900; line-height: 0.84; letter-spacing: -0.045em; }
        .s3 .m1 { top: 92px; }
        .s3 .m2 { top: 236px; }
        .s3 .rule { position: absolute; left: 96px; top: 392px; width: 1088px; height: 3px; background: var(--paper); transform-origin: 0 50%; }
        .s3 .of { position: absolute; left: 98px; top: 404px; margin: 0; padding: 0 0 0.25em; font: italic 400 76px/1.05 var(--serif); }
        .s3 .time {
            position: absolute; left: 88px; top: 488px; margin: 0; white-space: pre; font-size: 196px; font-weight: 900; line-height: 0.86;
            color: transparent; -webkit-background-clip: text; background-clip: text; transform-origin: 0 100%;
        }
        .s3 .ft { position: absolute; right: 104px; top: 108px; font: 700 112px/1 var(--mono); color: var(--yellow); }
        .s3 .tag { position: absolute; left: 930px; top: 560px; padding: 14px 22px; background: var(--yellow); color: var(--ink); font: 700 34px/1 var(--mono); }

        /* 04 Data */
        .s4 { background: var(--paper); color: var(--ink); }
        .s4 .title { position: absolute; left: 96px; top: 74px; display: flex; font-size: 56px; font-weight: 850; letter-spacing: -0.03em; line-height: 1; }
        .s4 .sub { position: absolute; left: 98px; top: 146px; min-height: 1em; white-space: pre; font: 600 12px/1 var(--mono); letter-spacing: 0.18em; color: rgba(22,22,29,0.6); }
        .s4 .round { position: absolute; right: 96px; top: 60px; width: 240px; text-align: right; }
        .s4 .round small { display: block; font: 600 12px/1 var(--mono); letter-spacing: 0.18em; color: rgba(22,22,29,0.6); }
        .s4 .digits { position: relative; height: 104px; overflow: hidden; }
        .s4 .digits span { position: absolute; right: 0; top: 0; font-size: 112px; font-weight: 900; line-height: 104px; letter-spacing: -0.04em; }
        .s4 .votes { font: 600 14px/1 var(--mono); letter-spacing: 0.08em; white-space: pre; }
        .s4 .axis { position: absolute; left: 0; top: 0; }
        .s4 .tick { position: absolute; top: 214px; height: 388px; width: 0; border-left: 1px solid rgba(22,22,29,0.16); transform-origin: 0 100%; }
        .s4 .tick span { position: absolute; top: 398px; left: 0; transform: translateX(-50%); font: 600 11px/1 var(--mono); color: rgba(22,22,29,0.55); white-space: pre; }
        .s4 .row { position: absolute; left: 96px; top: 224px; display: flex; align-items: center; height: 60px; }
        .s4 .icon { width: 40px; height: 40px; flex: none; }
        .s4 .name { width: 144px; padding-left: 16px; font: 600 13px/1 var(--mono); letter-spacing: 0.14em; }
        .s4 .bar { height: 40px; }
        .s4 .val { padding-left: 12px; font: 700 15px/1 var(--mono); white-space: pre; }
        .s4 .share { position: absolute; left: 890px; top: 380px; width: 300px; text-align: center; }
        .s4 .share b { display: block; font-size: 64px; font-weight: 900; line-height: 1; letter-spacing: -0.04em; }
        .s4 .share span { font: 600 11px/1 var(--mono); letter-spacing: 0.18em; color: rgba(22,22,29,0.6); }

        /* 05 Beat */
        .s5 { background: var(--yellow); }
        .s5 .cell { position: absolute; left: 0; top: 0; width: 64px; height: 64px; margin: -32px 0 0 -32px; }
        .s5 .boom { position: absolute; left: 640px; top: 360px; width: 200px; height: 200px; margin: -100px 0 0 -100px; border-radius: 50%; border: 3px solid var(--ink); }
        .s5 .band { position: absolute; left: 0; top: 288px; width: 1280px; height: 144px; background: var(--paper); display: flex; align-items: center; justify-content: center; }
        .s5 .words { display: flex; gap: 0.28em; font-size: 76px; font-weight: 900; letter-spacing: -0.03em; line-height: 1; color: var(--ink); }
        .s5 .words span { display: inline-block; padding: 0.06em 0.14em 0.1em; }

        /* 06 Depth */
        .s6 { background: var(--ink); }
        .s6 .light { position: absolute; left: 0; top: 0; border-radius: 50%; }
        .s6 .field { position: absolute; inset: 0; perspective: 1100px; }
        .s6 .ring { position: absolute; left: 640px; top: 372px; width: 0; height: 0; transform-style: preserve-3d; }
        .s6 .card {
            position: absolute; left: -125px; top: -170px; width: 250px; height: 340px; padding: 22px;
            display: flex; flex-direction: column; justify-content: space-between; backface-visibility: hidden;
        }
        .s6 .card i { display: block; width: 130px; height: 130px; margin: 18px auto 0; }
        .s6 .card b { font: 700 28px/1 var(--mono); }
        .s6 .card small { font: 600 11px/1 var(--mono); letter-spacing: 0.18em; opacity: 0.55; }

        /* 07 Cuts. Ten panels, one transition each. */
        .cut { display: flex; flex-direction: column; justify-content: center; padding-left: 120px; }
        .cut.red { background: var(--red); color: var(--paper); }
        .cut.paper { background: var(--paper); color: var(--ink); }
        .cut.blue { background: var(--blue); color: var(--paper); }
        .cut.yellow { background: var(--yellow); color: var(--ink); }
        .cut.ink { background: var(--ink); color: var(--paper); }
        .cut .word { font-size: 230px; font-weight: 900; line-height: 0.8; letter-spacing: -0.05em; }
        .cut .code { margin-top: 34px; font: 600 18px/1 var(--mono); letter-spacing: 0.04em; opacity: 0.75; white-space: pre; }
        .cut .glyph { position: absolute; right: 120px; top: 230px; width: 260px; height: 260px; background: currentColor; }
        .cut .count { position: absolute; right: 124px; bottom: 120px; font: 600 14px/1 var(--mono); letter-spacing: 0.18em; }

        /* 08 End */
        .s8 { background: var(--paper); color: var(--ink); }
        .s8 .mark { position: absolute; left: 0; right: 0; top: 214px; display: flex; justify-content: center; font-size: 220px; font-weight: 900; line-height: 1; letter-spacing: -0.05em; }
        .s8 .mark .ch { position: relative; }
        .s8 .tittle { position: absolute; left: 50%; top: 0.06em; width: 0.19em; height: 0.19em; margin-left: -0.095em; }
        .s8 .tagline { position: absolute; left: 0; right: 0; top: 476px; margin: 0; padding-bottom: 0.2em; text-align: center; font: italic 400 46px/1.1 var(--serif); }
        .s8 .credit { position: absolute; left: 0; right: 0; min-height: 1em; text-align: center; white-space: pre; font: 600 13px/1 var(--mono); letter-spacing: 0.18em; }
        .s8 .c1 { top: 590px; }
        .s8 .c2 { top: 614px; color: rgba(22,22,29,0.6); }

        /* The overlay */
        .hud { z-index: 3; font: 600 11px/1 var(--mono); letter-spacing: 0.18em; overflow: visible; pointer-events: none; }
        .hud .brand { position: absolute; left: 56px; top: 36px; display: flex; align-items: center; gap: 10px; }
        .hud .brand i { width: 9px; height: 9px; border-radius: 50%; background: var(--red); }
        .hud .chapters { position: absolute; right: 56px; top: 34px; width: 220px; height: 15px; overflow: hidden; }
        .hud .chapters span { position: absolute; right: 0; top: 2px; white-space: pre; }
        .hud .api { position: absolute; left: 56px; bottom: 40px; white-space: pre; letter-spacing: 0.06em; text-transform: none; }
        .hud .tc { position: absolute; right: 56px; bottom: 40px; white-space: pre; }
        .hud .track { position: absolute; left: 56px; right: 56px; bottom: 24px; height: 1px; background: currentColor; opacity: 0.25; }
        .hud .done { position: absolute; left: 56px; width: 1168px; bottom: 24px; height: 1px; background: currentColor; transform-origin: 0 50%; }
        .hud .notch { position: absolute; bottom: 21px; width: 1px; height: 7px; background: currentColor; }

        .about { max-width: 1280px; margin: 0 auto; padding: 0 24px 96px; color: #a9adc2; font-size: 14px; line-height: 1.55; }
        .about p { margin: 0 0 10px; }
        .about b { color: #e6e6f0; font-weight: 600; }
        .about code { color: #e6e6f0; font-size: 13px; }
    </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>Primary</strong>: a feature reel</div>
    </div>

    <div class="frame">
        <div id="stage" data-composition-id="primary" data-start="0" data-duration="36.5" data-width="1280" data-height="720" data-fps="30"
             data-composition-variables='[{"id":"word","type":"string","label":"Word","default":"kinetic"}]'>

            <!-- 01 Shape -->
            <div id="shape" class="layer s1" data-component="primary-shape" data-start="0" data-duration="5">
                <svg class="layer" viewBox="0 0 1280 720" width="1280" height="720" aria-hidden="true">
                    <path class="hair" pathLength="1" d="M 640 360 H 0" data-bind-style="hair"></path>
                    <path class="hair" pathLength="1" d="M 640 360 H 1280" data-bind-style="hair"></path>
                    <path class="hair" pathLength="1" d="M 640 360 V 0" data-bind-style="hair"></path>
                    <path class="hair" pathLength="1" d="M 640 360 V 720" data-bind-style="hair"></path>
                    <g transform="translate(640 360)">
                        <path class="outline" pathLength="1" data-bind-attr="{ d: triangleOutline }" data-bind-style="outline1"></path>
                        <path class="outline" pathLength="1" data-bind-attr="{ d: squareOutline }" data-bind-style="outline2"></path>
                        <path class="outline" pathLength="1" data-bind-attr="{ d: hexagonOutline }" data-bind-style="outline3"></path>
                        <path class="outline" pathLength="1" data-bind-attr="{ d: starOutline }" data-bind-style="outline4"></path>
                        <path data-bind-attr="{ d: form }" data-bind-style="hero"></path>
                    </g>
                </svg>
                <div class="roll" data-list="names" data-key="key"><template><span data-bind="text" data-bind-style="style"></span></template></div>
            </div>

            <!-- 02 Line -->
            <div id="line" class="layer s2" data-component="primary-line" data-start="shape - 0.6" data-duration="5" data-clip-transition="iris power2.inOut">
                <div class="layer cam" data-bind-style="camera">
                    <div class="layer dots" data-bind-style="dots"></div>
                    <svg class="layer" viewBox="0 0 1280 720" width="1280" height="720" aria-hidden="true">
                        <path class="ghost" data-bind-attr="{ d: route }" data-bind-style="ghost"></path>
                        <path class="route" pathLength="1" data-bind-attr="{ d: route }" data-bind-style="draw"></path>
                        <path class="box" pathLength="1" d="M 1070 165 H 1250 V 275 H 1070 Z" data-bind-style="box"></path>
                    </svg>
                    <div data-list="stations" data-key="key">
                        <template>
                            <div class="station" data-bind-style="place">
                                <i class="ring" data-bind-style="ring"></i>
                                <i class="dot" data-bind-style="dot"></i>
                                <div class="tag" data-bind-style="tagStyle"><b data-bind="index"></b><span data-bind="typed"></span></div>
                            </div>
                        </template>
                    </div>
                    <div class="arrow" data-bind-style="arrow">
                        <svg viewBox="-32 -16 64 32" width="64" height="32"><path data-bind-attr="{ d: arrowhead }"></path></svg>
                    </div>
                </div>
                <h2 class="headline" data-start="shape" data-duration="4.4" data-clip-enter="wipe from-left 0.6 power3.out">How a frame is made</h2>
            </div>

            <!-- 03 Type -->
            <div id="type" class="layer s3" data-component="primary-type" data-start="line - 0.5" data-duration="5" data-clip-transition="push from-right power3.inOut">
                <div class="block" data-bind-style="drift">
                    <div class="mask m1"><div class="big" data-list="line1" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div></div>
                    <div class="mask m2"><div class="big" data-list="line2" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div></div>
                    <div class="rule" data-bind-style="rule"></div>
                    <p class="of" data-bind-style="of">is a function of</p>
                    <p class="time" data-bind-style="stamp">TIME.</p>
                    <div class="ft" data-bind-style="ft">f(t)</div>
                    <div class="tag" data-bind-style="tag">seek(t)</div>
                </div>
            </div>

            <!-- 04 Data -->
            <div id="data" class="layer s4" data-component="primary-data" data-start="type - 0.6" data-duration="5.6" data-clip-transition="clock">
                <div class="title" data-list="title" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div>
                <div class="sub" data-bind="sub"></div>
                <div class="round" data-bind-style="roundIn">
                    <small>ROUND</small>
                    <div class="digits" data-list="digits" data-key="key"><template><span data-bind="text" data-bind-style="style"></span></template></div>
                    <div class="votes" data-bind="votes"></div>
                </div>
                <div class="axis" data-list="axis" data-key="key">
                    <template><div class="tick" data-bind-style="style"><span data-bind="label" data-bind-style="labelStyle"></span></div></template>
                </div>
                <div data-list="bars" data-key="id">
                    <template>
                        <div class="row" data-bind-style="style">
                            <i class="icon" data-bind-style="icon"></i>
                            <span class="name" data-bind="name"></span>
                            <div class="bar" data-bind-style="bar"></div>
                            <span class="val" data-bind="shown" data-bind-style="valStyle"></span>
                        </div>
                    </template>
                </div>
                <div data-list="slices" data-key="key"><template><i class="layer" data-bind-style="style"></i></template></div>
                <div class="share" data-bind-style="shareIn"><b data-bind="leaderShare"></b><span data-bind="leaderName"></span></div>
            </div>

            <!-- 05 Beat -->
            <div id="beat" class="layer s5" data-component="primary-beat" data-start="data - 0.6" data-duration="5" data-clip-transition="flip">
                <div data-list="cells" data-key="key"><template><i class="cell" data-bind-style="style"></i></template></div>
                <div data-list="booms" data-key="key"><template><i class="boom" data-bind-style="style"></i></template></div>
                <div class="band" data-bind-style="band">
                    <div class="words" data-list="words" data-key="key"><template><span data-bind="text" data-bind-style="style"></span></template></div>
                </div>
            </div>

            <!-- 06 Depth -->
            <div id="depth" class="layer s6" data-component="primary-depth" data-start="beat - 0.5" data-duration="5" data-clip-transition="cover from-bottom power3.out">
                <div class="layer" data-bind-style="bokeh">
                    <div data-pool="lights" data-key="id"><template><i class="light" data-bind-style="style"></i></template></div>
                </div>
                <div class="field">
                    <div class="ring" data-bind-style="ring" data-list="cards" data-key="key">
                        <template>
                            <div class="card" data-bind-style="style">
                                <small data-bind="index"></small>
                                <i data-bind-style="icon"></i>
                                <b data-bind="name"></b>
                            </div>
                        </template>
                    </div>
                </div>
            </div>

            <!-- 07 Cuts. Each panel replaces the one before it with the
                 transition set on its clip, and one scene runs on all ten. -->
            <div id="cut1" class="layer cut red" data-component="primary-cut" data-prop-n="1" data-prop-glyph="'hexagon'" data-start="depth - 0.3" data-duration="0.8" data-clip-transition="fade">
                <div class="word" data-bind-style="word">fade</div><div class="code" data-bind="code"></div><i class="glyph" data-bind-style="glyph"></i><div class="count" data-bind="count"></div>
            </div>
            <div id="cut2" class="layer cut paper" data-component="primary-cut" data-prop-n="2" data-prop-glyph="'triangle'" data-start="cut1 - 0.3" data-duration="0.8" data-clip-transition="push from-right">
                <div class="word" data-bind-style="word">push</div><div class="code" data-bind="code"></div><i class="glyph" data-bind-style="glyph"></i><div class="count" data-bind="count"></div>
            </div>
            <div id="cut3" class="layer cut blue" data-component="primary-cut" data-prop-n="3" data-prop-glyph="'square'" data-start="cut2 - 0.3" data-duration="0.8" data-clip-transition="cover from-bottom">
                <div class="word" data-bind-style="word">cover</div><div class="code" data-bind="code"></div><i class="glyph" data-bind-style="glyph"></i><div class="count" data-bind="count"></div>
            </div>
            <div id="cut4" class="layer cut yellow" data-component="primary-cut" data-prop-n="4" data-prop-glyph="'star'" data-start="cut3 - 0.3" data-duration="0.8" data-clip-transition="reveal from-left">
                <div class="word" data-bind-style="word">reveal</div><div class="code" data-bind="code"></div><i class="glyph" data-bind-style="glyph"></i><div class="count" data-bind="count"></div>
            </div>
            <div id="cut5" class="layer cut ink" data-component="primary-cut" data-prop-n="5" data-prop-glyph="'hexagon'" data-start="cut4 - 0.3" data-duration="0.8" data-clip-transition="wipe from-left">
                <div class="word" data-bind-style="word">wipe</div><div class="code" data-bind="code"></div><i class="glyph" data-bind-style="glyph"></i><div class="count" data-bind="count"></div>
            </div>
            <div id="cut6" class="layer cut red" data-component="primary-cut" data-prop-n="6" data-prop-glyph="'circle'" data-start="cut5 - 0.3" data-duration="0.8" data-clip-transition="iris">
                <div class="word" data-bind-style="word">iris</div><div class="code" data-bind="code"></div><i class="glyph" data-bind-style="glyph"></i><div class="count" data-bind="count"></div>
            </div>
            <div id="cut7" class="layer cut paper" data-component="primary-cut" data-prop-n="7" data-prop-glyph="'star'" data-start="cut6 - 0.3" data-duration="0.8" data-clip-transition="clock">
                <div class="word" data-bind-style="word">clock</div><div class="code" data-bind="code"></div><i class="glyph" data-bind-style="glyph"></i><div class="count" data-bind="count"></div>
            </div>
            <div id="cut8" class="layer cut blue" data-component="primary-cut" data-prop-n="8" data-prop-glyph="'triangle'" data-start="cut7 - 0.3" data-duration="0.8" data-clip-transition="zoom">
                <div class="word" data-bind-style="word">zoom</div><div class="code" data-bind="code"></div><i class="glyph" data-bind-style="glyph"></i><div class="count" data-bind="count"></div>
            </div>
            <div id="cut9" class="layer cut yellow" data-component="primary-cut" data-prop-n="9" data-prop-glyph="'square'" data-start="cut8 - 0.3" data-duration="0.8" data-clip-transition="blur">
                <div class="word" data-bind-style="word">blur</div><div class="code" data-bind="code"></div><i class="glyph" data-bind-style="glyph"></i><div class="count" data-bind="count"></div>
            </div>
            <div id="cut10" class="layer cut ink" data-component="primary-cut" data-prop-n="10" data-prop-glyph="'circle'" data-start="cut9 - 0.3" data-duration="0.8" data-clip-transition="flip">
                <div class="word" data-bind-style="word">flip</div><div class="code" data-bind="code"></div><i class="glyph" data-bind-style="glyph"></i><div class="count" data-bind="count"></div>
            </div>

            <!-- 08 End -->
            <div class="layer s8" data-component="primary-end" data-start="cut10 - 0.3" data-duration="4" data-clip-transition="zoom power3.out">
                <div class="mark" data-list="mark" data-key="key">
                    <template><span class="ch" data-bind-style="style"><span data-bind="text"></span><i class="tittle" data-show="tittle" data-bind-style="dot"></i></span></template>
                </div>
                <p class="tagline" data-start="cut10 + 0.6" data-duration="3.1" data-clip-enter="wipe from-left 0.7 power3.out">Video scenes for WildflowerJS</p>
                <div class="credit c1" data-bind="credit1"></div>
                <div class="credit c2" data-bind="credit2"></div>
            </div>

            <!-- The overlay -->
            <div class="layer hud" data-component="primary-hud" data-start="0" data-duration="36.5">
                <div class="layer" data-bind-style="ink">
                    <div class="brand"><i></i>PRIMARY</div>
                    <div class="chapters" data-list="chapters" data-key="key"><template><span data-bind="text" data-bind-style="style"></span></template></div>
                    <div class="api" data-bind="api"></div>
                    <div class="tc" data-bind="timecode"></div>
                    <div class="track"></div>
                    <div class="done" data-bind-style="done"></div>
                    <div data-list="notches" data-key="key"><template><i class="notch" data-bind-style="style"></i></template></div>
                </div>
            </div>
        </div>
    </div>

    <div class="about">
        <p>Eight chapters, each one part of Kinetic. Play, scrub, check or render it with the bar at the bottom.</p>
    </div>
</body>
</html>

app.js

// Primary runs 36.5 seconds in eight chapters, with an overlay on top. Each
// chapter is a scene on its own clip of the composition, and the cut into
// each clip is a transition Kinetic applies from an attribute on the clip.
// Every value on the stage is computed from the clip's local time, so any
// frame renders on its own and in any order.

const {
    scene, styleAt, stagger, interpolate, spring, ramp, focus, random, noise,
    split, typewriter, format, ticks, rank, captions, beat, moment, transition, matchPaths, shape, distribute,
} = wildflower.kinetic;

// ---- Shared ------------------------------------------------------------------

const PAPER = '#f2ede3';
const INK = '#16161d';
const RED = '#ff4632';
const BLUE = '#2a4bff';
const YELLOW = '#ffc81e';
const STONE = '#b9b1a2';

const fx = (v, digits) => v.toFixed(digits === undefined ? 2 : digits);
const px = (v) => fx(v) + 'px';

// Shapes for clip-path, each drawn in a square box `size` pixels wide.
const GLYPHS = {
    circle: (size) => shape('sector', { radius: size * 0.46, to: 360, cx: size / 2, cy: size / 2 }),
    triangle: (size) => shape('polygon', { sides: 3, radius: size * 0.56, cx: size / 2, cy: size * 0.6 }),
    square: (size) => shape('polygon', { sides: 4, radius: size * 0.58, rotate: 45, cx: size / 2, cy: size / 2 }),
    hexagon: (size) => shape('polygon', { sides: 6, radius: size * 0.5, rotate: 30, cx: size / 2, cy: size / 2 }),
    star: (size) => shape('star', { points: 5, outer: size * 0.52, inner: size * 0.22, cx: size / 2, cy: size * 0.54 }),
};
const glyph = (kind, size) => 'path("' + GLYPHS[kind](size) + '")';

// A row for each letter of a text, each styled at its own delay. A space
// keeps a row of its own, so the words keep their gaps.
function letters(text, t, gap, tracks) {
    const rows = split(text);
    return rows.map((r) => ({ key: r.key, text: r.text, style: r.space ? {} : styleAt(t - stagger(r.i, rows.length, { each: gap }), tracks) }));
}

// A track that holds one value for the whole clip.
const hold = (v) => ({ at: [0, 1], to: [v, v] });

// ---- 01 Shape ------------------------------------------------------------------

// Five forms, each centred on 0, 0, which the group round them in the markup
// puts at the stage's centre, so each turns and scales about its own centre.
// Each turn is the form's rest angle, a whole multiple of its symmetry, so a
// form settles square to the frame.
const FORMS = [
    { name: 'CIRCLE', d: shape('sector', { radius: 150, to: 360 }), fill: RED, turn: 0 },
    { name: 'TRIANGLE', d: shape('polygon', { sides: 3, radius: 180 }), fill: YELLOW, turn: 120 },
    { name: 'SQUARE', d: shape('polygon', { sides: 4, radius: 170, rotate: 45 }), fill: BLUE, turn: 270 },
    { name: 'HEXAGON', d: shape('polygon', { sides: 6, radius: 165, rotate: 30 }), fill: INK, turn: 360 },
    { name: 'STAR', d: shape('star', { points: 5, outer: 185, inner: 76 }), fill: RED, turn: 432 },
];
// 60 segments is the least common multiple of the forms' corners (3, 4, 6
// and the star's 10), so every side of every form gets an even share.
const MORPHS = matchPaths(FORMS.map((f) => f.d), { segments: 60 });
const CHANGES = [1.1, 1.8, 2.5, 3.2];   // s, the start of each morph
const MORPH = 0.4;                      // s, each morph's length

// A track through one value per form. Each change moves to the next value,
// which holds until the change after it.
function throughForms(values, morphEase) {
    const at = [], to = [], eases = [];
    CHANGES.forEach((c, k) => {
        at.push(c, c + MORPH);
        to.push(values[k], values[k + 1]);
        eases.push(morphEase);
        if (k < CHANGES.length - 1) eases.push('linear');
    });
    return { at, to, ease: eases };
}

// The fill switches at the middle of each morph, where the form moves
// fastest. Two primaries mixed over the morph would pass through grey.
const FILL = { at: [], to: [], ease: 'steps(1)' };
CHANGES.forEach((c, k) => {
    FILL.at.push(c, c + MORPH / 2);
    FILL.to.push(FORMS[k].fill, FORMS[k + 1].fill);
});

// The form turns as it morphs, then spins and grows past the frame while the
// next chapter opens over it.
const TURN = throughForms(FORMS.map((f) => f.turn), 'expo.inOut');
TURN.at.push(3.9, 5);
TURN.to.push(432, 540);
TURN.ease.push('linear', 'power3.in');

// An outline of each form after the circle, larger, draws itself as that
// form arrives, and fades back as the next one draws.
const OUTLINES = [
    { d: shape('polygon', { sides: 3, radius: 270 }), at: 1.25, spin: 8 },
    { d: shape('polygon', { sides: 4, radius: 262, rotate: 45 }), at: 1.95, spin: -6 },
    { d: shape('polygon', { sides: 6, radius: 300, rotate: 30 }), at: 2.65, spin: 5 },
    { d: shape('star', { points: 5, outer: 330, inner: 136 }), at: 3.35, spin: -4 },
];
const outline = (o, k) => {
    const next = OUTLINES[k + 1];
    return {
        rotate: { at: [0, 5], to: [-o.spin, o.spin] },
        strokeDashoffset: { at: [o.at, o.at + 0.8], to: [1, 0], ease: 'power3.inOut' },
        opacity: next
            ? { at: [next.at, next.at + 0.4, 3.9, 4.3], to: [1, 0.22, 0.22, 0] }
            : { at: [3.9, 4.3], to: [1, 0] },
    };
};

scene('primary-shape', {
    keyframes: {
        form: throughForms(MORPHS, 'expo.inOut'),
        hero: {
            rotate: TURN,
            scale: { at: [0.15, 1.15, 3.9, 5], to: [0, 1, 1, 18], ease: ['spring', 'linear', 'power3.in'] },
            fill: FILL,
        },
        hair: {
            strokeDashoffset: { at: [0.1, 0.9], to: [1, 0], ease: 'expo.out' },
            opacity: { at: [3.7, 4.2], to: [1, 0] },
        },
        outline1: outline(OUTLINES[0], 0),
        outline2: outline(OUTLINES[1], 1),
        outline3: outline(OUTLINES[2], 2),
        outline4: outline(OUTLINES[3], 3),
    },
    computed: {
        triangleOutline() { return OUTLINES[0].d; },
        squareOutline() { return OUTLINES[1].d; },
        hexagonOutline() { return OUTLINES[2].d; },
        starOutline() { return OUTLINES[3].d; },
        // The form's name rolls up as each morph runs.
        names() {
            const t = this.local;
            const k = CHANGES.reduce((sum, c) => sum + ramp(t, c, c + MORPH, 'expo.inOut'), 0);
            const shown = ramp(t, 0.5, 0.9) * (1 - ramp(t, 3.8, 4.1));
            return FORMS.map((f, i) => ({ key: i, text: f.name, style: { transform: 'translateY(' + fx((i - k) * 100) + '%)', opacity: fx(shown) } }));
        },
    },
});

// ---- 02 Line -------------------------------------------------------------------

const ROUTE = 'M 110 560 C 250 560 300 300 460 300 C 630 300 640 520 560 520 C 480 520 470 380 610 330 C 760 276 820 470 960 430 C 1070 398 1060 236 1160 220';
const ON_ROUTE = 'path("' + ROUTE + '")';
const DRAW = [0.6, 3.8];       // s, the line draws from the first to the second
const DRAW_EASE = 'power2.inOut';

// The time the tip of the line reaches a fraction of the route. It inverts
// the draw's eased progress by halving the interval.
function reaches(fraction) {
    let lo = DRAW[0], hi = DRAW[1];
    for (let k = 0; k < 32; k++) {
        const mid = (lo + hi) / 2;
        if (ramp(mid, DRAW[0], DRAW[1], DRAW_EASE) < fraction) lo = mid; else hi = mid;
    }
    return hi;
}

const STATIONS = [
    { fraction: 0.14, label: 'time', above: false },
    { fraction: 0.43, label: 'keyframes', above: true },
    { fraction: 0.72, label: 'styles', above: true },
    { fraction: 1, label: 'frame', above: true },
].map((s, i) => Object.assign({ key: i, index: '0' + (i + 1), at: reaches(s.fraction) }, s));

scene('primary-line', {
    keyframes: {
        draw: { strokeDashoffset: { at: DRAW, to: [1, 0], ease: DRAW_EASE } },
        // The arrow moves with the tip of the line. It has the same span and
        // ease, and offset-distance measures the route the way pathLength does.
        travel: {
            offsetDistance: { at: DRAW, to: [0, 100], unit: '%', ease: DRAW_EASE },
            opacity: { at: [0.45, 0.6, 3.85, 4.1], to: [0, 1, 1, 0] },
        },
        ghost: { opacity: { at: [0.1, 0.6], to: [0, 1] } },
        // The frame at the end of the route, drawn from its corner. Square
        // caps close a right-angled shape as the stroke arrives.
        box: { strokeDashoffset: { at: [3.75, 4.35], to: [1, 0], ease: 'power3.inOut' } },
        dots: { backgroundPosition: { at: [0, 1], to: ['0px 0px', '-10px 4px'], extrapolate: 'extend' } },
        camera: { scale: { at: [3.6, 5], to: [1, 1.22], ease: 'power2.in' } },
    },
    computed: {
        route() { return ROUTE; },
        arrowhead() { return shape('arrow', { from: [-26, 0], to: [26, 0], width: 12, head: 24 }); },
        arrow() { return Object.assign({ offsetPath: ON_ROUTE }, this.travel); },
        // Each station springs up as the tip reaches it, sends out a ring,
        // and types its name.
        stations() {
            const t = this.local;
            return STATIONS.map((s) => {
                const since = t - s.at;
                const ring = ramp(t, s.at, s.at + 0.7, 'power2.out');
                return {
                    key: s.key,
                    index: s.index,
                    typed: typewriter(since - 0.08, s.label, 26),
                    place: { offsetPath: ON_ROUTE, offsetDistance: fx(s.fraction * 100) + '%' },
                    dot: styleAt(t, { scale: Math.max(0, spring(Math.max(0, since), { stiffness: 320, damping: 14 })) }),
                    ring: styleAt(t, { scale: 1 + 2.6 * ring, opacity: since < 0 ? 0 : 1 - ring }),
                    tagStyle: { top: s.above ? '-50px' : '18px', opacity: since < 0 ? '0' : '1' },
                };
            });
        },
    },
});

// ---- 03 Type -------------------------------------------------------------------

const RISE = {
    translateY: { at: [0, 0.75], to: [112, 0], unit: '%', ease: 'expo.out' },
    rotate: { at: [0, 0.75], to: [10, 0], ease: 'expo.out' },
};
// A band of yellow that crosses the word, inside its gradient.
const shine = (x) => 'linear-gradient(100deg, ' + PAPER + ' ' + x + '%, ' + YELLOW + ' ' + (x + 20) + '%, ' + PAPER + ' ' + (x + 40) + '%)';

scene('primary-type', {
    keyframes: {
        bg: { at: [0, 5], to: [BLUE, '#1d36d6'] },
        rule: { scaleX: { at: [0.85, 1.6], to: [0, 1], ease: 'expo.inOut' } },
        of: {
            clipPath: { at: [1.1, 1.75], to: ['inset(0% 100% 0% 0%)', 'inset(0% 0% 0% 0%)'], ease: 'power3.out' },
            translateX: { at: [1.1, 1.75], to: [-24, 0], ease: 'power3.out' },
        },
        stamp: {
            clipPath: { at: [1.5, 2.2], to: ['inset(100% 0% 0% 0%)', 'inset(0% 0% 0% 0%)'], ease: 'expo.out' },
            translateY: { at: [1.5, 2.2], to: [60, 0], ease: 'expo.out' },
            skewX: { at: [1.5, 2.4], to: [-16, 0], ease: 'expo.out' },
            letterSpacing: { at: [1.5, 2.6], to: [0.24, -0.045], unit: 'em', ease: 'expo.out' },
            backgroundImage: { at: [2.5, 3.5], to: [shine(-40), shine(100)], ease: 'power2.inOut' },
        },
        ft: {
            opacity: { at: [1.9, 2.4], to: [0, 1] },
            scale: { at: [1.9, 2.6], to: [1.25, 1], ease: 'expo.out' },
            blur: { at: [1.9, 2.6], to: [16, 0], ease: 'power2.out' },
        },
        // The sticker springs on, then lifts off the page.
        tag: {
            translateY: { at: [3.1, 3.5], to: [0, -6], ease: 'power2.out' },
            rotate: { at: [2.7, 3.7], to: [-22, -5], ease: 'spring' },
            scale: { at: [2.7, 3.7], to: [0, 1], ease: 'spring' },
            boxShadow: { at: [3.1, 3.5], to: ['0px 0px 0px rgba(22, 22, 29, 1)', '10px 10px 0px rgba(22, 22, 29, 1)'], ease: 'power2.out' },
        },
        drift: { translateX: { at: [0, 5], to: [12, -18] } },
    },
    root: { style: { background: 'bg' } },
    computed: {
        line1() { return letters('EVERY', this.local - 0.45, 0.045, RISE); },
        line2() { return letters('FRAME', this.local - 0.6, 0.045, RISE); },
    },
});

// ---- 04 Data -------------------------------------------------------------------

// Made-up votes for a made-up poll, one value per round.
const POLL = [
    { id: 'circle', name: 'CIRCLE', color: RED, values: [3200, 5400, 7600, 9100] },
    { id: 'triangle', name: 'TRIANGLE', color: YELLOW, values: [4100, 5000, 6900, 9800] },
    { id: 'square', name: 'SQUARE', color: BLUE, values: [2600, 5900, 7100, 8300] },
    { id: 'hexagon', name: 'HEXAGON', color: INK, values: [1800, 3300, 6200, 7400] },
    { id: 'star', name: 'STAR', color: STONE, values: [3500, 4600, 5300, 6100] },
];
const ROUNDS = POLL[0].values.length;
const TICKS = ticks(0, Math.max(...POLL.flatMap((r) => r.values)), 5);
const TOP = TICKS[TICKS.length - 1];   // the value that fills the plot
const PLOT = 560;                      // px
const PLOT_X = 280;                    // px, where the bars start
const ROW = 64;                        // px per row
const RACE = [1.9, 4.9];               // s, the rounds roll from the first to the last
const FIRST = rank(0, POLL, (r, k) => r.values[k]).map((r) => r.row.id);   // the first round's order
const DONUT = { cx: 1040, cy: 410, radius: 150, inner: 96 };
const GAP = 1.2;                       // degrees left clear at each end of a slice
const TITLE_IN = {
    opacity: { at: [0, 0.3], to: [0, 1] },
    translateY: { at: [0, 0.6], to: [30, 0], ease: 'expo.out' },
};

scene('primary-data', {
    keyframes: {
        roundIn: {
            opacity: { at: [0.8, 1.4], to: [0, 1], ease: 'expo.out' },
            translateY: { at: [0.8, 1.4], to: [20, 0], ease: 'expo.out' },
        },
        shareIn: { opacity: { at: [1.5, 2], to: [0, 1] } },
    },
    computed: {
        // 0 is the first round, 3 the last, with fractions between. Each
        // round holds for 45% of its second, then the next one rolls in.
        round() {
            const raw = interpolate(this.local, RACE, [0, ROUNDS - 1]);
            const k = Math.floor(raw);
            return k + interpolate(raw - k, [0.45, 1], [0, 1], { ease: 'power2.inOut' });
        },
        // The bars grow in, top first, before the race starts.
        grown() {
            return FIRST.map((id, slot) => ramp(this.local, 0.9 + slot * 0.1, 1.8 + slot * 0.1, 'power3.out'));
        },
        standings() {
            const grown = this.grown;
            return rank(this.round, POLL, (r, k) => r.values[k]).map((s) => {
                const grow = grown[FIRST.indexOf(s.row.id)];
                return Object.assign({}, s, { grow, shown: s.value * grow });
            });
        },
        total() { return this.standings.reduce((sum, s) => sum + s.shown, 0); },
        title() { return letters('Favourite shape', this.local - 0.55, 0.025, TITLE_IN); },
        sub() { return typewriter(this.local - 0.9, 'A POLL IN FOUR ROUNDS OF MADE-UP DATA', 40); },
        digits() {
            return POLL[0].values.map((_, i) => ({ key: i, text: String(i + 1), style: { transform: 'translateY(' + fx((i - this.round) * 100) + '%)' } }));
        },
        votes() { return format(Math.round(this.total)) + ' VOTES'; },
        axis() {
            return TICKS.map((v, i) => {
                const p = ramp(this.local, 0.7 + i * 0.05, 1.3 + i * 0.05, 'expo.out');
                return { key: v, label: format(v, { notation: 'compact' }), style: { left: px(PLOT_X + PLOT * v / TOP), ...styleAt(this.local, { scaleY: p }) }, labelStyle: { opacity: fx(p) } };
            });
        },
        bars() {
            return this.standings.map((s) => ({
                id: s.row.id,
                name: s.row.name,
                style: styleAt(this.local, { translateY: s.position * ROW }),
                icon: { background: s.row.color, clipPath: glyph(s.row.id, 40) },
                bar: { width: px(PLOT * s.shown / TOP), background: s.row.color },
                shown: format(Math.round(s.shown)),
                valStyle: { opacity: fx(Math.min(1, s.grow * 4)) },
            }));
        },
        // Each slice is a layer the size of the stage, cut to a ring sector
        // by clip-path. A slice keeps its place round the ring and changes
        // its size, and the leader's slice stands out from the ring. A slice
        // too small to draw is cut to nothing with inset(50%), so every
        // frame writes the same three properties.
        slices() {
            const sweep = ramp(this.local, 1, 1.9, 'power3.inOut');
            const byId = {};
            for (const s of this.standings) byId[s.row.id] = s;
            const total = this.total || 1;
            let from = 0;
            return POLL.map((row) => {
                const s = byId[row.id];
                const span = 360 * s.shown / total * sweep;
                const lead = Math.max(0, 1 - s.position);
                const mid = (from + span / 2) * Math.PI / 180;
                const d = span > 2 * GAP ? shape('sector', Object.assign({ from: from + GAP, to: from + span - GAP }, DONUT)) : '';
                from += span;
                return {
                    key: row.id,
                    style: {
                        background: row.color,
                        clipPath: d ? 'path("' + d + '")' : 'inset(50%)',
                        ...styleAt(this.local, { translateX: 14 * lead * Math.sin(mid), translateY: -14 * lead * Math.cos(mid) }),
                    },
                };
            });
        },
        leader() { return this.standings.reduce((a, b) => (b.position < a.position ? b : a)); },
        leaderShare() { return format(100 * this.leader.shown / (this.total || 1), { maximumFractionDigits: 0 }) + '%'; },
        leaderName() { return this.leader.row.name; },
    },
});

// ---- 05 Beat -------------------------------------------------------------------

// 120 beats a minute from 0.6 s, every fourth beat a downbeat. Each beat
// falls on a whole frame, since 0.5 s is 15 frames.
const BEATS = [];
for (let k = 0; k < 9; k++) BEATS.push({ time: 0.6 + k * 0.5, strength: k % 4 === 0 ? 1 : 0.55 });
const DOWNBEATS = BEATS.filter((b) => b.strength === 1).map((b) => b.time);
// One word on each of the first six beats.
const WORDS = ['Every', 'frame', 'lands', 'on', 'the', 'beat.'].map((text, k) => ({ id: k, text, start: 0.6 + k * 0.5, end: 1.02 + k * 0.5 }));

// Ten columns by five rows of points, 128 by 144 px apart, centred on
// (640, 360).
const CELLS = [];
{
    const rnd = random(5);
    const kinds = ['circle', 'square', 'triangle'];
    for (const { i, x, y, col: c, row: r } of distribute('grid', 50, { columns: 10, cell: [128, 144], cx: 640, cy: 360 })) {
        const pick = rnd();
        CELLS.push({
            key: i, c, r, x, y,
            clip: glyph(kinds[(c + r * 2) % 3], 64),
            color: pick < 0.14 ? RED : pick < 0.22 ? BLUE : INK,
        });
    }
}

scene('primary-beat', {
    keyframes: {
        band: { clipPath: { at: [0.35, 0.75], to: ['inset(0% 100% 0% 0%)', 'inset(0% 0% 0% 0%)'], ease: 'expo.out' } },
    },
    computed: {
        // Every cell pulses on each beat, a little later the further right it
        // is, and turns a quarter on each downbeat.
        cells() {
            const t = this.local;
            return CELLS.map((cell) => {
                const lag = cell.c * 0.025;
                const appear = Math.max(0, spring(Math.max(0, t - 0.45 - (cell.c + cell.r) * 0.03), { stiffness: 260, damping: 18 }));
                const pulse = beat(t - lag, BEATS, { decay: 0.16 });
                const turn = DOWNBEATS.reduce((sum, at) => sum + 90 * ramp(t - lag, at, at + 0.4, 'back.out'), 0);
                const wobble = 6 * noise(cell.c * 0.7 + cell.r * 1.3 + t * 1.5, 9);
                return {
                    key: cell.key,
                    style: {
                        background: cell.color,
                        clipPath: cell.clip,
                        ...styleAt(t, { translateX: cell.x, translateY: cell.y, rotate: turn + wobble, scale: appear * (0.74 + 0.36 * pulse) }),
                    },
                };
            });
        },
        // A ring goes out from the centre on each downbeat.
        booms() {
            const t = this.local;
            return DOWNBEATS.map((at, i) => {
                const p = ramp(t, at, at + 0.9, 'power2.out');
                return { key: i, style: styleAt(t, { scale: 0.2 + 5 * p, opacity: t < at ? 0 : 1 - p }) };
            });
        },
        line() { return captions(this.local, WORDS, { maxWords: 6, maxGap: 1, hold: 3 }); },
        // The active word is filled with ink, and words not yet reached are faint.
        words() {
            return this.line.words.map((w) => {
                const active = w.state === 'active';
                return {
                    key: w.key,
                    text: w.text,
                    style: {
                        background: active ? INK : 'transparent',
                        color: active ? YELLOW : INK,
                        opacity: w.state === 'future' ? '0.18' : '1',
                        ...styleAt(this.local, { scale: active ? 1 + 0.14 * (1 - w.p) : 1 }),
                    },
                };
            });
        },
    },
});

// ---- 06 Depth ------------------------------------------------------------------

const RADIUS = 600;                   // px, the ring's radius
const SPIN = { at: [0, 5], to: [40, -130], ease: 'power1.inOut' };
// Each card has a face for each side, each hidden while it is turned away,
// so the far side of the ring shows the backs the right way round.
const CARDS = ['seek', 'styleAt', 'ease', 'spring', 'focus', 'noise', 'random', 'render'].map((name, i) => ({
    name, index: '0' + (i + 1),
    angle: i * 45,
    color: [RED, BLUE, YELLOW, INK][i % 4],
    clip: glyph(['circle', 'triangle', 'square', 'hexagon', 'star'][i % 5], 130),
}));
const FACES = CARDS.flatMap((card, i) => [
    { key: 'front' + i, card, back: false },
    { key: 'back' + i, card, back: true },
]);
const LIGHTS = 60;
const LIGHT_COLORS = [RED, BLUE, YELLOW, PAPER];

scene('primary-depth', {
    edges: { enter: 0.6 },
    pools: { lights: {} },
    keyframes: {
        ring: { translateZ: hold(-640), rotateX: hold(-10), rotateY: SPIN },
        spin: SPIN,
        // The focal plane racks from the back of the ring to the front.
        plane: { at: [0.5, 2.6], to: [-RADIUS, RADIUS], ease: 'power2.inOut' },
    },
    init() {
        const rnd = random(31);
        for (let i = 0; i < LIGHTS; i++) {
            this.pools.lights.add({
                id: i, x: rnd() * 1280, y: rnd() * 720, z: -1000 + rnd() * 1200, base: 4 + rnd() * 8,
                color: LIGHT_COLORS[Math.floor(rnd() * LIGHT_COLORS.length)], style: {},
            });
        }
    },
    // The lights drift. Each one out of focus becomes a larger, softer disc,
    // drawn with a radial gradient and no blur filter.
    seek(local) {
        const plane = this.plane;
        this.pools.lights.forEach((l) => {
            const blur = focus(l.z, plane, { range: 260, max: 1 });
            const size = l.base * (1 + 5 * blur);
            const edge = 100 - 65 * blur;
            const drift = noise(l.id * 0.37 + local * 0.2, 31) * 40;
            this.pools.lights.update(l.id, {
                style: {
                    width: px(size),
                    height: px(size),
                    ...styleAt(local, { translateX: l.x + drift - size / 2, translateY: l.y - size / 2 }),
                    opacity: fx(0.55 - 0.3 * blur, 3),
                    background: 'radial-gradient(circle, ' + l.color + ' ' + fx(edge - 35, 0) + '%, transparent ' + fx(edge, 0) + '%)',
                },
            });
        });
    },
    computed: {
        bokeh() { return { opacity: fx(this.presence, 3) }; },
        // Each card's depth comes from its angle round the ring, and its blur
        // from that depth's distance to the focal plane.
        cards() {
            return FACES.map(({ key, card, back }) => {
                const z = RADIUS * Math.cos((card.angle + this.spin) * Math.PI / 180);
                // Eight levels of blur, so a card's filter is drawn again
                // only when its level changes.
                const blur = focus(z, this.plane, { range: 150, max: 9, steps: 8 });
                return {
                    key,
                    name: card.name,
                    index: card.index,
                    icon: { background: back ? PAPER : card.color, clipPath: card.clip },
                    style: {
                        transform: 'rotateY(' + card.angle + 'deg) translateZ(' + RADIUS + 'px)' + (back ? ' rotateY(180deg)' : ''),
                        background: back ? '#24242e' : PAPER,
                        color: back ? PAPER : INK,
                        filter: 'blur(' + fx(blur) + 'px)',
                        opacity: fx(0.45 + 0.55 * (z + RADIUS) / (2 * RADIUS), 3),
                    },
                };
            });
        },
    },
});

// ---- 07 Cuts -------------------------------------------------------------------

// One scene on all ten panels. Each panel's number and glyph are props on its
// clip: data-prop-n="3" and data-prop-glyph="'square'".
scene('primary-cut', {
    props: {
        n: { type: Number, default: 0 },
        glyph: { type: String, default: 'circle' },
    },
    keyframes: {
        word: { translateX: { at: [0, 0.6], to: [70, 0], ease: 'expo.out' } },
        turn: {
            rotate: { at: [0, 0.8], to: [-40, 0], ease: 'expo.out' },
            scale: { at: [0, 0.8], to: [0.6, 1], ease: 'expo.out' },
        },
    },
    computed: {
        count() { return String(this.props.n).padStart(2, '0') + ' / 10'; },
        // The label is the panel's own attribute, so the two always agree.
        code() { return 'data-clip-transition="' + this.element.dataset.clipTransition + '"'; },
        glyph() { return Object.assign({ clipPath: glyph(this.props.glyph, 260) }, this.turn); },
    },
});

// ---- 08 End --------------------------------------------------------------------

// The letters drop in, and each i's dot falls in last as a shape of its own.
const DROP = {
    opacity: { at: [0, 0.12], to: [0, 1] },
    translateY: { at: [0, 1], to: [-110, 0], unit: '%', ease: 'spring' },
};
const TITTLES = [{ glyph: 'circle', color: RED }, { glyph: 'square', color: BLUE }, { glyph: 'triangle', color: YELLOW }];

scene('primary-end', {
    // The word is a composition variable, declared with its default on the
    // root in data-composition-variables.
    inputs: { word: String },
    computed: {
        mark() {
            const t = this.local;
            let dots = 0;
            const rows = split(this.inputs.word);
            return rows.map((r) => {
                const isI = r.text === 'i';
                const row = { key: r.key, text: isI ? 'ı' : r.text, tittle: isI, style: r.space ? {} : styleAt(t - 0.12 - stagger(r.i, rows.length, { each: 0.06 }), DROP), dot: {} };
                if (isI) {
                    const k = dots++ % TITTLES.length;
                    const since = t - 1.15 - k * 0.16;
                    const land = spring(Math.max(0, since), { stiffness: 240, damping: 11 });
                    row.dot = {
                        background: TITTLES[k].color,
                        clipPath: glyph(TITTLES[k].glyph, 42),
                        ...styleAt(t, { translateY: -420 * (1 - land), rotate: 90 * (1 - land) }),
                        opacity: since < 0 ? '0' : '1',
                    };
                }
                return row;
            });
        },
        credit1() { return typewriter(this.local - 2, 'PRIMARY', 24); },
        credit2() { return typewriter(this.local - 2.2, 'A KINETIC SHOWCASE, 36.5 SECONDS AT 30 FPS', 40); },
    },
});

// ---- The overlay ---------------------------------------------------------------

// The chapters' start times, as the clips' data-start chain in index.html
// resolves them. Change a clip's length there and these change with it.
const DURATION = 36.5;
const CHAPTERS = [
    { at: 0, name: '01  SHAPE', api: 'shape()   matchPaths()   keyframes' },
    { at: 4.4, name: '02  LINE', api: 'strokeDashoffset   offset-path   data-clip-enter' },
    { at: 8.9, name: '03  TYPE', api: 'split()   styleAt()   text tracks   root:' },
    { at: 13.3, name: '04  DATA', api: "rank()   ticks()   format()   shape('sector')" },
    { at: 18.3, name: '05  BEAT', api: 'beat()   captions()   noise()' },
    { at: 22.8, name: '06  DEPTH', api: 'rotateY   focus()   random()   pools' },
    { at: 27.5, name: '07  CUT', api: 'data-clip-transition' },
    { at: 32.5, name: '08  END', api: 'inputs' },
];
// The overlay's colour at each cut is ink over the light backgrounds and
// paper over the dark ones, and it changes over the first 0.3 s of the cut.
const OVER = [
    [0, INK], [4.4, PAPER], [13.3, INK], [22.8, PAPER],
    [28, INK], [28.5, PAPER], [29, INK], [29.5, PAPER], [30.5, INK], [31, PAPER], [31.5, INK], [32, PAPER], [32.5, INK],
];
const OVER_AT = [0], OVER_TO = [INK];
for (let k = 1; k < OVER.length; k++) {
    OVER_AT.push(OVER[k][0], OVER[k][0] + 0.3);
    OVER_TO.push(OVER[k - 1][1], OVER[k][1]);
}
const pad = (n) => String(n).padStart(2, '0');

scene('primary-hud', {
    keyframes: {
        ink: {
            color: { at: OVER_AT, to: OVER_TO },
            opacity: { at: [32.6, 33.2], to: [1, 0] },
        },
    },
    computed: {
        chapter() { return moment(this.time, CHAPTERS).index; },
        // Each chapter's name pushes the one before it up and out, with the
        // styles a push between two clips writes.
        chapters() {
            const k = this.chapter;
            const p = ramp(this.time, CHAPTERS[k].at, CHAPTERS[k].at + 0.5, 'power3.inOut');
            const push = transition('push', p, { direction: 'from-bottom' });
            return CHAPTERS.map((c, i) => {
                const style = { translate: '0% 0%', opacity: '1' };
                if (i === k && k > 0) Object.assign(style, push.in);
                else if (i === k - 1) Object.assign(style, push.out);
                else if (i !== k) style.opacity = '0';
                return { key: i, text: c.name, style };
            });
        },
        api() {
            const c = CHAPTERS[this.chapter];
            return typewriter(this.time - c.at - 0.35, c.api, 45);
        },
        // Seconds and frames, at the composition's frame rate.
        timecode() {
            const fps = this.stores.kinetic.fps;
            return pad(Math.floor(this.clipFrame / fps)) + ':' + pad(this.clipFrame % fps);
        },
        done() { return styleAt(this.time, { scaleX: this.progress }); },
        notches() { return CHAPTERS.map((c, i) => ({ key: i, style: { left: px(56 + 1168 * c.at / DURATION) } })); },
    },
});