PageSourceSearch

https://fermi.ai/hp-m-assets/beat-tutor.js

js fermi.ai collected 2026-10-05 11:59:53 UTC 37,338 bytes, 711 lines download raw bytes

1/* ============================================================================
2   Beat 2 - the AI tutor, mobile. Builder B.
3
4   Exactly one global:
5
6     BeatTutor.init(root)        root is the #beatTutor element.
7     BeatTutor.setProgress(p)    p is 0..1 across the beat's 150svh.
8
9   No listeners on window or document. The host's single rAF loop calls
10   setProgress with its own lerped value. The one rAF this file does schedule is
11   the bounded 45-frame catch-up in kickSettle, which exists only because the
12   pane applies its own classes a frame or two after setTarget; it cancels itself
13   and never drives progress.
14
15   ONE SCREEN. The headline is up from the first frame and stays to the last;
16   the Euler window sits below it in a bordered card, the whole pane at native
17   type size. Nothing lifts out and nothing takes the screen. What this file
18   changes across the beat is the session inside the window, plus a 12px settle
19   on the card over the first tenth of the beat.
20
21   ROUND M3b. The lede, the pane's floating Back and menu buttons and 40px of
22   the dock are cut (see beat-tutor.css), and everything they freed is in the
23   card: 350 x 520 becomes 350 x 630 and the board window 288px becomes 490px at
24   390x844. Two numbers here move with them, CARD_AR and the column's own top
25   inset, and the second is now read off the pane rather than hardcoded.
26
27   ROUND M5 answers the client's "why is there space between the tutor diagram
28   and the closed caption?". The card's height was set by the ratio and the room
29   under the copy with no reference to how much column the pane has to show, and
30   the shortfall - 38.6px at 390x844, 116.7px at 375x667 - landed as white above
31   the caption bubble. Three changes, all here: fit() also clamps the card to
32   what the lit column can fill, moveCol may move the column down by TOP_DROP so
33   a column shorter than the band can still meet the dock, and DOCK_GAP comes to
34   16 to match the clearance the column's own padding already gives the last
35   block. The figure now sits 16.6px above the caption bubble at every viewport
36   in the target set, against 16.6px for the KEY IDEA card one beat later.
37   The ask bar's own defect is entirely in beat-tutor.css, item 3.
38
39   ROUND M6 answers "for the ai tutor, start from the first part instead of
40   directly coming to the bottom of the tutor board and have the user scroll a
41   litle bit to show some content being scrolled." Three changes, and they are a
42   set - any one of them alone makes the beat worse:
43
44     1. MAP's floor moves from the pane's beat 2 to its beat 1, so the beat
45        opens on the session's first content instead of on a board that already
46        holds every block. The note on MAP says why the floor is not 0.00.
47     2. fit() clamps the card against the OPENING block as well as the tallest
48        one. Without it the new floor is round M2's half-empty card returning:
49        212px of lit column in a 484px board window. With it the card is 440
50        rather than 624 at 390x844, and the figure inside it is unchanged,
51        because the figure's box comes from the card's width.
52     3. moveCol places the column as a function of p rather than snapping and
53        letting a 0.5s CSS transition catch up. The board now travels 348px
54        across the beat, in two moves tied to the scroll, and a cold load at any
55        p lands on the frame that scrolling there would have produced.
56
57   Everything below is a fraction of the beat, never a pixel count or an svh, so
58   the TUTOR=short variant (100svh instead of 150svh, a knob in Builder A's
59   beats.json) needs nothing from this file: the figure lands and completes at
60   the same fractions and only the thumb cost changes.
61
62   The one measurement it does make is the fit: the card's height from its width
63   and the room left under the copy, and the real height of the pane's floating
64   dock at that width. That runs at init and once more on document.fonts.ready,
65   because Bebas Neue's fallback metrics are much wider and would leave the copy
66   two lines taller. It writes two custom properties; it never runs on scroll.
67   ========================================================================= */
68(function () {
69  'use strict';
70
71  /* Beat progress -> pane session progress, as control points.
72     Gotcha 14: the whole session streams before the figure arrives, so a
73     straight 0..1 pass lands the drawing far too late in a 150svh beat. The
74     pane is started mid-session instead.
75
76     The mobile pane's own timeline (its Readme, section 3), in pane progress q:
77
78       q 0.00-0.09  beat 0  avatar and turn title
79       q 0.09-0.20  beat 1  the violet callout
80       q 0.20-0.30  beat 2  the figure frame lands, step 0, axes and grid only
81       q 0.30-0.44  beat 3  step 1     q 0.44-0.58  beat 4  step 2
82       q 0.58-0.72  beat 5  step 3     q 0.72-0.86  beat 6  step 5
83       q 0.86-1.00  beat 7  the KEY IDEA card, step 6
84
85     THE FLOOR IS 0.10, AND IT IS 0.10 RATHER THAN 0.00 FOR ONE REASON.
86     Client, round M6: "for the ai tutor, start from the first part instead of
87     directly coming to the bottom of the tutor board and have the user scroll a
88     litle bit to show some content being scrolled."
89
90     Rounds M2 to M5 ran a 0.20 floor, which opens on the pane's beat 2 with the
91     figure frame already on the board and nothing drawn in it. That was chosen
92     to stop the card reading half empty, and it is what he has now rejected: a
93     board that arrives with every block already on it has nothing left to
94     scroll, so the beat reads as a screenshot with a drawing animating inside
95     it. The floor is now inside the pane's beat 1, so the beat opens on the
96     session's first content and the board fills as the visitor scrolls.
97
98     IT IS NOT 0.00, AND THAT COSTS NOTHING HE ASKED FOR. Beat 0 is the greeting,
99     "Welcome. Today we'll explore how air resistance changes the optimal launch
100     angle.". Two things are wrong with playing it. Its caption measures 88px at
101     a 350 card, three lines, against Sriram's round 6b rule that the tutor's
102     talk is two lines a beat and against the pane's own 66px caption reserve,
103     so the bubble would stand proud of its wrap. And it puts nothing on the
104     board that this fragment keeps: its blocks are 0, the "S" user avatar, cut
105     here by the data-ep="0" rule in beat-tutor.css, and 1, the turn title,
106     which beat 1 carries anyway. So beat 1 IS the session's first content: the
107     turn title and the violet callout, under a 65px two-line caption.
108
109     WHAT THE OPENING FRAME COSTS, MEASURED. At the opening the lit column is
110     the title and the callout, 212px at a 350 card, against a figure block of
111     252px. A card sized for the figure cannot be filled by the opening, so the
112     card is now also clamped against the OPENING block (OPEN_SLACK in fit(),
113     and the ladder that set it is in the note there). Tallest blank band inside
114     the card at 390x844, measured the same way as round M5's 7.1%:
115
116       p 0.00  the opening, title and callout       101px   23.0%
117       p 0.10  the figure lands, below the fold      44px   10.1%
118       p 0.30  drawing                               28px    6.4%
119       p 0.55  drawing                               28px    6.4%
120       p 0.85  KEY IDEA                              16px    3.6%
121
122     The 23.0% is the price of the client's own instruction and it is not
123     reducible below it: a session that has just started has two blocks in it.
124     What it is NOT is round M2's 52.5%, and it is at the BOTTOM of the card,
125     under the last thing the tutor said, where a chat that has not filled its
126     screen yet always has it.
127
128     Everything is a fraction of the beat and never an svh or a pixel count, so
129     TUTOR=short (100svh instead of 150svh) plays the identical sequence.
130
131       opening, title + callout   p 0.00 -> 0.10   (10% of the beat)
132       figure lands               p 0.10   |  in place by p 0.20
133       first curve                p 0.203
134       curves drawing             p 0.203 -> 0.78           (58% of the beat)
135       figure complete            p 0.78   |  KEY IDEA lands p 0.78,
136                                               in place by 0.88, held to 1.00
137
138     against the brief's "figure on screen by 0.30, complete by 0.90". The
139     drawing owns 58% of the beat against 66% before; 10% of that went to the
140     opening the client asked for and 8% to landing the KEY IDEA early enough
141     to be read, which at the old 0.84 would have been still sliding into place
142     at p 0.85. */
143  var MAP = [[0.00, 0.10], [0.10, 0.20], [0.78, 0.86], [0.88, 1.00], [1.00, 1.00]];
144
145  /* the card's arrival. Opacity is NOT animated: the window is fully present at
146     p = 0 so a cold load at the top of the beat is a complete frame, and only a
147     12px settle reads as arrival.
148     CARD_RISE is 12 and not more because --bt-bot is 14: a bigger start offset
149     pushes the card's bottom corner past the stage's edge and the very first
150     frame of the beat is shot with 2px of it clipped. */
151  var CARD_IN_A = 0.00, CARD_IN_B = 0.10, CARD_RISE = 12;
152
153  /* the card's shape. Held as a ratio so a wider phone gets a wider card and a
154     taller board window instead of a bigger gap, clamped to the room left under
155     the copy, AND clamped to what the session itself can fill (see fit()).
156     Nothing here is a viewport unit or a beat fraction; it is the box the pane
157     is given, once, at init.
158
159     1.80 and not more: with the lede gone, 350 x 1.80 = 630 at 390x844 and the
160     stack's own centring splits whatever slack is left above and below the
161     group, so the card has air rather than sitting hard on the safe-area floor.
162     The room clamp is what protects a narrower phone, where the headline takes
163     a third line: at 360x780 the room is 563px and the card takes all of it.
164     MAX_H is 720 because the card is capped at the pane's native 390px wide and
165     390 x 1.80 = 702. MIN_H is 400, which is the smallest card whose board
166     window (400 - 12 - 128 = 260) still holds the 350x250 figure whole.
167
168     THE FILL CLAMP IS ROUND M5's FIX AND IT IS THE ONE THAT MATTERS. See fit().
169     Since the host nav came down from 82px to 56px the room clamp almost never
170     binds, so without the fill clamp the freed 26px would land as white inside
171     the card rather than as a bigger window. */
172  var CARD_AR = 1.80, MIN_H = 400, MAX_H = 720;
173
174  /* How much white the OPENING frame is allowed to carry under its last block.
175     Round M6's one taste number, and the ladder that chose it is in fit(). */
176  var OPEN_SLACK = 100;
177
178  function clamp(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
179  function ramp(v, a, b) { return b === a ? (v >= b ? 1 : 0) : clamp((v - a) / (b - a)); }
180  function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
181
182  function piecewise(pts, p) {
183    for (var i = 1; i < pts.length; i++) {
184      if (p <= pts[i][0]) {
185        var a = pts[i - 1], b = pts[i];
186        var t = b[0] === a[0] ? 1 : (p - a[0]) / (b[0] - a[0]);
187        return a[1] + (b[1] - a[1]) * t;
188      }
189    }
190    return pts[pts.length - 1][1];
191  }
192
193  /* MAP read backwards: the beat progress at which the pane reaches a given q.
194     It is what turns a pane beat boundary into a page progress, which is how
195     the column's travel is tied to the thumb rather than to a timer - see
196     moveCol. MAP is monotonic in q, so this is a straight walk; a q below the
197     floor answers 0, which is correct, that q is never played. */
198  function invMap(q) {
199    var pts = MAP;
200    if (q <= pts[0][1]) return pts[0][0];
201    for (var i = 1; i < pts.length; i++) {
202      if (q <= pts[i][1]) {
203        var a = pts[i - 1], b = pts[i];
204        var t = b[1] === a[1] ? 1 : (q - a[1]) / (b[1] - a[1]);
205        return a[0] + (b[0] - a[0]) * t;
206      }
207    }
208    return pts[pts.length - 1][0];
209  }
210
211  var root = null, stack = null, copy = null, card = null;
212  var api = null, last = -1, lastQ = -1;
213
214  /* --------------------------------------------------------------------------
215     The fit. Writes the card's height and the pane's real dock height. This is
216     the one place in the fragment that reflows the pane, and it runs twice, both
217     times at init - never on scroll.
218     ------------------------------------------------------------------------ */
219  function fit() {
220    if (!root || !stack || !card || !copy) return;
221    var cs = getComputedStyle(stack);
222    var padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
223    var gap = parseFloat(getComputedStyle(card).marginTop) || 0;
224    var avail = stack.clientHeight - padY - copy.getBoundingClientRect().height - gap;
225    var w = card.getBoundingClientRect().width;        /* CSS owns the width: one gutter each side */
226    var h = w * CARD_AR;
227    if (h > avail) h = avail;
228    if (h > MAX_H) h = MAX_H;
229    /* MIN_H IS AN ADVISORY FLOOR, NOT A HARD ONE, AND THAT DISTINCTION IS THE
230       WHOLE POINT OF IT. 100svh is not the device height: an iPhone 13 in
231       Safari with its chrome showing resolves 100svh to about 659px, not 844,
232       and the stage this beat lives in is 100svh. At 659 the room under the
233       copy is 495px and at a 600px stage it is 436px, both of which the card
234       can take. Below a 564px stage the room falls under MIN_H, and a hard
235       floor there would make the card taller than the stack that holds it -
236       which, with justify-content:center inside an overflow:hidden stage, saws
237       the ask bar off the bottom and the headline off the top at the same time.
238       A card smaller than the figure is a worse-looking beat; a card with its
239       input bar cut in half is a broken one. The room wins.
240
241       Nothing else in the beat carries a fixed height that the stage can
242       outrun: the pane's own 480px min-height is overridden to 0 in
243       beat-tutor.css, its dock is absolutely positioned at the card's bottom
244       and its board is flex:1 with min-height:0, so every pixel of the squeeze
245       lands on the board window and none of it on the caption bubble or the
246       ask bar. */
247    if (h < MIN_H) h = Math.min(MIN_H, avail);
248    if (h < 120) h = 120;
249    /* provisional, so the dock below lays out in a box the right order of size.
250       Nothing measured after this depends on it: the column's blocks, the figure
251       and the dock are all width-driven. */
252    root.style.setProperty('--bt-cardh', h.toFixed(1) + 'px');
253
254    /* The dock is a real component, not a constant: at 350 wide its caption can
255       take a line more than it does at the pane's native 390. Measure what it
256       actually is and hand that to --epm-dock, which is what the board column
257       pads its bottom by and what moveCol reads.
258
259       --epm-dock is how much of the bottom the dock COVERS, which is not its box
260       height: its top 48px of padding is the gradient fade the board is meant to
261       run into, and counting it would reserve a band of empty paper above the
262       caption bubble. Measured box minus that padding is the opaque part. After
263       the M3b cuts the box is 176 at 350 wide and the padding is still 48, so
264       128 - against 168 before them and the pane's own 148 at 390.
265
266       It is measured after the height is written so the dock has settled into
267       the box it will live in. Nothing in the dock's own height depends on
268       --epm-dock, so there is no loop. */
269    var dockH = 128;
270    if (dockEl) {
271      var ds = getComputedStyle(dockEl);
272      var dh = Math.round(dockEl.getBoundingClientRect().height - (parseFloat(ds.paddingTop) || 0));
273      if (dh > 0) { dockH = dh; root.style.setProperty('--bt-dock', dh + 'px'); }
274    }
275    /* the column's own top inset, off the live style rather than restated here.
276       beat-tutor.css sets it to 12px now that the pane's floating chrome is
277       cut; reading it means a change there needs no change in this file. */
278    if (colEl) {
279      var ct = parseFloat(getComputedStyle(colEl).paddingTop);
280      if (ct >= 0) COL_TOP = ct;
281    }
282
283    /* THE FILL CLAMP. The defect it fixes, in the client's words: "why is there
284       space between the tutor diagram and the closed caption?"
285
286       The card's height used to be set by the ratio and the room under the copy
287       alone, with no reference to how much column the pane actually has to
288       show. At 390x844 that gave a 630px card, a 502px board above the dock,
289       and only 464px of lit column to put in it - the session title, the violet
290       callout and the figure. moveCol bottom-aligns the newest block to the
291       dock, but its scroll clamps at 0 (a column cannot scroll backwards), so
292       the 38px shortfall landed as white between the figure's ruled bottom edge
293       and the caption bubble for the first 84% of the beat. Measured 38.6px at
294       390x844, 51.7 at 402x874, 82.1 at 430x932, and 116.7 at 375x667, where
295       the block was top-aligned instead and the whole shortfall sat at the
296       bottom in one piece.
297
298       So the card is now also clamped to the height at which the lit column
299       reaches the dock:
300
301         card <= (bottom of the tallest block the beat dwells on)
302                 + DOCK_GAP + the dock's opaque height + TOP_DROP
303
304       The last block is left out of that maximum on purpose. KEY IDEA is lit
305       only from p 0.84 and the column's own padding-bottom (--epm-dock + 16)
306       already governs where it lands; counting it would make the clamp read the
307       full 772px column, which never binds, and the beat would be back to
308       leading on 38px of white.
309
310       TOP_DROP is the one thing the clamp does not have to buy back, because
311       moveCol is now allowed to move the column DOWN by that much (see there).
312       It comes back as a top inset on the session title, where 12px was tight
313       anyway. */
314    if (colEl && blockEls.length > 1) {
315      measureCol();
316      var fill = 0;
317      for (var i = 0; i < blockEls.length - 1; i++) {
318        if (tops[i] + hs[i] > fill) fill = tops[i] + hs[i];
319      }
320      var capH = fill + DOCK_GAP + dockH + TOP_DROP;
321
322      /* THE OPENING CLAMP, ROUND M6. The fill clamp above sizes the card to the
323         block the beat DWELLS on, the figure. Since the floor moved into the
324         pane's beat 1 the beat also has to open on a column that is only the
325         title and the callout - 212px at a 350 card against the figure's 252 -
326         and a card sized for the figure cannot be filled by that. Left alone it
327         is round M2's fault coming back: at the 624px card round M5 landed on,
328         the opening frame is 272px of white below the callout, 44% of the card.
329
330         So the card is clamped a second time, against the OPENING block, with
331         OPEN_SLACK as the white the opening frame is allowed to carry under its
332         last block. It is a real trade and it is the trade this round is: every
333         pixel of card above (opening bottom + OPEN_SLACK) is a pixel the
334         opening frame cannot fill, and every pixel below it is a pixel of board
335         window. The ladder was built and shot at 390x844 before it was picked -
336         opening bottom 212, dock 128, figure block 252:
337
338           OPEN_SLACK   card   board window   opening blank   board travel
339             (none)      624       484         285px 45.7%       164px
340              200        540       400         201px 37.2%       248px
341              140        480       340         141px 29.4%       308px
342              100        440       300         101px 23.0%       348px
343               60        400       260          61px 15.2%       388px
344
345         100 is what ships, and the reason it is not lower is the board window:
346         at 60 it is 260px against a 252px figure, so the figure fills the band
347         and the taller-than-the-band branch in moveCol starts firing on a short
348         stage. At 100 the window is 300px, 48px of room around the figure.
349
350         THE COST IS NOT WHAT IT LOOKS LIKE. The card goes 624 -> 440, but the
351         FIGURE does not change by a pixel: it is 252px at a 350 card whatever
352         the card's height, because its box comes from the card's WIDTH through
353         the iframe's own 420/300 aspect. What comes off is paper the beat could
354         not have filled, and what it buys is the 348px of travel the client
355         asked to see. The type is untouched: 19px title, 15.5px body, 13.5px
356         callout, 15px caption, all the product's own. */
357      var open = 0, oi = openNewest(blockEls.length - 1);
358      if (oi >= 0 && oi < blockEls.length) open = tops[oi] + hs[oi];
359      if (open > 0) {
360        var capO = open + TOP_DROP + dockH + OPEN_SLACK;
361        if (capO < capH) capH = capO;
362      }
363
364      if (fill > 0 && capH < h) {
365        /* the same advisory floor as above, and for the same reason: on a short
366           stage the room has already taken the card under MIN_H and the clamp
367           must not put it back over the room. */
368        var floor = Math.min(MIN_H, avail);
369        h = capH < floor ? floor : capH;
370        if (h < 120) h = 120;
371        root.style.setProperty('--bt-cardh', h.toFixed(1) + 'px');
372      }
373    }
374
375    curN = -2; yApplied = null;                        /* the band changed, so re-place the column */
376    moveCol();
377  }
378
379  /* --------------------------------------------------------------------------
380     The transcript column, moved here instead of by the pane.
381
382     The pane centres the newest block in the band between its own top inset
383     (64px, re-pointed at 12px here now that the chrome it cleared is cut) and
384     the top of its floating dock. On the late beats, once the column is
385     finally taller than the board, that leaves white between the newest card
386     and the caption bubble. This bottom-aligns the newest block to the dock
387     instead, at every beat, so the bubble always reads as narrating the thing
388     directly above it. Round M4 clamped that alignment at 0 and round M5 does
389     not: the floor is -TOP_DROP, because a column shorter than the band has
390     nothing above to scroll away and can only meet the dock by moving down.
391     Everything the card's own fill clamp could not take out comes out there,
392     and it comes out at the top of the board rather than in the middle of the
393     frame.
394
395     The class is only set once the column, the board and the blocks are all
396     found, so a pane built differently keeps its own behaviour. The easing is a
397     CSS transition, so this file still owns no progress loop.
398     ------------------------------------------------------------------------ */
399  var colEl = null, boardEl = null, blockEls = [], paneEl = null, dockEl = null, onCls = 'epm-on', mobile = true;
400  var tops = [], hs = [];
401  /* Clearance between the newest block and the top of the dock's opaque part,
402     which is where the caption bubble's own top edge sits (the bubble measures
403     65px inside a 66px wrap, bottom-anchored).
404
405     16, down from 24 in round M4. 16 is not a taste number: the pane's column
406     already carries padding-bottom: calc(var(--epm-dock) + 16px), so the LAST
407     block in the session is landed 16px above the dock by the scroll clamp
408     whatever this constant says. At 24 the figure sat 24px clear and the KEY
409     IDEA card 16px clear, in the same card, one beat apart. Now both are 16 and
410     the bubble reads as floating just under whatever it is narrating. */
411  var DOCK_GAP = 16;
412  /* How much of the beat one move of the column takes.
413
414     ROUND M6, AND IT IS THE HALF OF THE CLIENT'S NOTE THAT MATTERS: "have the
415     user scroll a litle bit to show some content being scrolled." Until now the
416     column jumped to its new place on a 0.5s CSS transition, which is a timer,
417     not the thumb; and with the 0.20 floor there was only ever one jump, at
418     p 0.84. Now the newest block's landing place is reached over MOVE_SPAN of
419     the BEAT, measured from the page progress at which the pane's own beat
420     introduced that block. Two consequences worth having:
421
422       - the board scrolls while the visitor scrolls, at roughly the page's own
423         rate. 0.10 of a 150svh beat is 127px of thumb, against the 184px the
424         column travels when the figure arrives and the 164px it travels when
425         KEY IDEA does. 348px of board travel across the beat, in two moves,
426         where round M5 had one move of 148px at p 0.84 and nothing before it.
427       - a cold load anywhere in the beat lands on the frame that scrolling
428         there would have produced, because the column's offset is an absolute
429         function of p and not an ease from wherever it happened to be. The 0.5s
430         transition could not do that: on a cold load it started from the
431         opening position and ran on wall-clock time.
432
433     The CSS transition is turned off (.bt-colp) whenever this path is live. It
434     needs the pane's own beat table, which is read through the documented
435     EulerPaneM.beatAt API at init; a pane that does not expose it keeps the
436     transition and the old step behaviour. */
437  var MOVE_SPAN = 0.10;
438  /* How far moveCol may push the column DOWN, past the top of its own content.
439     A column that is shorter than the band cannot be bottom-aligned by
440     scrolling - there is nothing above to scroll away - so the shortfall used
441     to land as white above the caption bubble. Letting the column drop moves
442     that white to the top of the board, where it reads as the card's own inset
443     above the session title rather than as a hole in the middle of the frame.
444     16px, and fit()'s fill clamp is what guarantees the drop is never asked to
445     be more than this. */
446  var TOP_DROP = 16;
447  /* The column's own top inset. It was the pane's 64px clearance under the
448     floating chrome; with the chrome cut, beat-tutor.css re-points it at 12px,
449     which is breathing room off the card's top edge and nothing more. It is
450     READ rather than restated, so the two files cannot drift: fit() writes it
451     from the live computed style and this is only the value before the first
452     measurement. */
453  var COL_TOP = 12;
454
455  function offsetIn(el, stop) {
456    var t = 0;
457    while (el && el !== stop) { t += el.offsetTop; el = el.offsetParent; }
458    return t;
459  }
460  function measureCol() {
461    tops = []; hs = [];
462    for (var i = 0; i < blockEls.length; i++) {
463      tops.push(offsetIn(blockEls[i], colEl));
464      hs.push(blockEls[i].offsetHeight);
465    }
466  }
467
468  /* The pane applies its own reveal classes from its own easing loop, one or
469     more frames after setTarget. The host may stop calling setProgress before
470     that lands, so a short bounded catch-up runs after every change of target:
471     45 frames, self-cancelling, no listeners. */
472  var settle = 0, sraf = 0;
473  function settleLoop() {
474    sraf = 0;
475    moveCol();
476    if (--settle > 0) sraf = requestAnimationFrame(settleLoop);
477  }
478  function kickSettle() {
479    settle = 45;
480    if (!sraf) sraf = requestAnimationFrame(settleLoop);
481  }
482
483  /* WHERE THE NEWEST BLOCK WANTS TO LAND. Split out of moveCol in round M6 so
484     that the same rule can be asked about a block that is not the current one:
485     the ramp in moveCol needs the block it is travelling FROM as well as the
486     one it is travelling to, and asking for both by index is what makes the
487     column's offset an absolute function of p. */
488  function targetFor(n) {
489    var bh = boardEl.clientHeight, y;
490    if (mobile) {
491      var dock = parseFloat(getComputedStyle(paneEl).getPropertyValue('--epm-dock')) || 128;
492      /* the band the visitor can actually see: under the column's own top
493         inset, above the floating dock.
494
495         ONE RULE NOW, NOT TWO. The newest block is bottom-aligned to the dock,
496         so the caption bubble always reads as narrating the thing directly
497         above it. The only exception is a block TALLER than the whole band,
498         which is top-aligned so its head is not sliced off against the card's
499         top edge with its tail under the dock.
500
501         Round M4 had a 0.62-of-the-band threshold instead, and it produced a
502         gap at both ends of it. Above the threshold (375x667: the figure at
503         0.66) the block top-aligned and the whole shortfall sat under it,
504         116.7px of it. Below the threshold (390x844: the figure at 0.51) the
505         block bottom-aligned but the scroll clamped at 0 and 38.6px of it sat
506         under it anyway, because the lit column was shorter than the band. The
507         threshold was picking which end of the card the hole appeared at, not
508         whether there was one. fit()'s fill clamp is what removes the hole; the
509         threshold then has nothing left to decide.
510
511         Measured at 350 wide after round M6's opening clamp: card 440, band
512         300px, figure block 252px, KEY IDEA 148px. The figure is 0.84 of the
513         band, so it bottom-aligns with 48px of the callout still showing above
514         it. The taller-than-the-band branch is the guard on that 48px: at a
515         600svh stage the room clamp takes the card to 436 and the band to 296,
516         and a copy block that ever took another line would push the figure
517         across it. */
518      var band = bh - COL_TOP - dock;
519      if (hs[n] > band) y = tops[n] - COL_TOP - 8;
520      else y = tops[n] + hs[n] + DOCK_GAP + dock - bh;    /* newest block sits just above the dock */
521    } else if (hs[n] > bh - 32) {
522      y = tops[n] - 12;                                    /* taller than the board: top-align it */
523    } else {
524      y = tops[n] + hs[n] - bh + 20;
525    }
526    /* The floor is -TOP_DROP, not 0. A negative y translates the column DOWN,
527       which is the only way a column shorter than the band can have its last
528       lit block meet the dock. Anything the fill clamp could not take out of
529       the card comes out here, and it comes out at the top of the board rather
530       than as a hole above the caption bubble. */
531    var maxY = Math.max(0, colEl.scrollHeight - bh);
532    return Math.max(mobile ? -TOP_DROP : 0, Math.min(y, maxY));
533  }
534
535  /* The pane's own beat table, read at init through the documented
536     EulerPaneM.beatAt(q) API by walking it beat by beat. What is wanted from it
537     is one number per block: the pane progress at which that block first became
538     the newest one. invMap turns each into a page progress, and that is the
539     start of the block's move. Nothing here hardcodes the table - a pane with a
540     different session, or the desktop capture, either answers or does not, and
541     if it does not the fragment falls back to the old step behaviour. */
542  var qNewest = null;
543  function readBeats() {
544    qNewest = null;
545    if (!api || typeof api.beatAt !== 'function') return;
546    try {
547      var out = {}, acc = -1, q = 0, guard = 0;
548      while (q < 1 && guard++ < 64) {
549        var b = api.beatAt(q);
550        if (!b || typeof b.b !== 'number' || b.b <= q) break;
551        var bl = b.blocks || [], m = -1;
552        for (var i = 0; i < bl.length; i++) if (bl[i] > m) m = bl[i];
553        if (m > acc) { acc = m; out[m] = b.a; }
554        q = b.b + 1e-6;
555      }
556      if (acc >= 0) qNewest = out;
557    } catch (e) { qNewest = null; }
558  }
559  /* the page progress at which block n started moving into place, and the block
560     it displaced. Both are read off qNewest, so both are the same at every p. */
561  function moveStart(n) {
562    return (qNewest && qNewest[n] !== undefined) ? invMap(qNewest[n]) : -1;
563  }
564  function prevNewest(n) {
565    if (!qNewest) return -1;
566    var best = -1;
567    for (var k in qNewest) { k = +k; if (k < n && k > best) best = k; }
568    return best;
569  }
570  /* which block is the newest one in the beat's OPENING frame, i.e. at the
571     MAP's own floor. fit() clamps the card against it. Without a beat table the
572     answer is the block before the one the beat dwells on, which is the same
573     answer for this session and a reasonable one for any other. */
574  function openNewest(fallback) {
575    var q0 = piecewise(MAP, 0), best = -1;
576    if (qNewest) {
577      for (var k in qNewest) { k = +k; if (qNewest[k] <= q0 + 1e-9 && k > best) best = k; }
578      if (best >= 0) return best;
579    }
580    return fallback === undefined ? -1 : fallback - 1;
581  }
582
583  /* THE PLACEMENT. y is a pure function of p: which block is newest decides the
584     two ends of the move, and p decides how far along it is. Nothing reads the
585     currently applied offset, which is what makes a cold load at any p identical
586     to scrolling to that p. */
587  var curN = -2, yTo = 0, yFrom = 0, pMove = 0, yApplied = null;
588
589  function moveCol() {
590    if (!colEl || !blockEls.length) return;
591    var n = -1;
592    for (var i = 0; i < blockEls.length; i++) if (blockEls[i].classList.contains(onCls)) n = i;
593    if (n !== curN) {
594      curN = n;
595      if (n < 0) { yTo = yFrom = 0; pMove = 0; }
596      else {
597        measureCol();      /* the figure's height only settles once its srcdoc reports an aspect, so re-read on each change of the newest block - eight reads across the whole beat */
598        yTo = targetFor(n);
599        var pn = prevNewest(n);
600        yFrom = pn >= 0 ? targetFor(pn) : yTo;
601        pMove = moveStart(n);
602        if (pMove < 0) { yFrom = yTo; pMove = 0; }   /* no beat table: snap, and let the CSS transition do the easing as it did before round M6 */
603      }
604    }
605    var y = qNewest ? yFrom + (yTo - yFrom) * easeOut(ramp(last < 0 ? 0 : last, pMove, pMove + MOVE_SPAN))
606                    : yTo;
607    if (yApplied === null || Math.abs(y - yApplied) > 0.05) {
608      yApplied = y;
609      colEl.style.setProperty('--bt-coly', (-y).toFixed(1) + 'px');
610    }
611  }
612
613  function findPane() {
614    if (!root) return null;
615    return root.querySelector('#euler-pane-m') || root.querySelector('#euler-pane');
616  }
617
618  function init(el) {
619    inited = true;
620    root = el || document.getElementById('beatTutor');
621    if (!root) return;
622    stack = root.querySelector('.bt-stack');
623    copy  = root.querySelector('.bt-copy');
624    card  = root.querySelector('.bt-card');
625
626    var paneRoot = findPane();
627    if (!paneRoot) {
628      /* the pane token in beat-tutor.html was never substituted. Hide the card
629         rather than print the raw token, and leave the copy working. */
630      root.classList.add('bt-nopane');
631    } else {
632      api = window.EulerPaneM || window.EulerPane || null;
633      try { if (api && typeof api.init === 'function') api.init(paneRoot); } catch (e) {}
634      /* the shared v2 pane freezes itself fully drawn below 1001px. If the
635         mobile pane inherits that guard, undo it; this beat drives the session. */
636      paneRoot.classList.remove('ep-static');
637
638      paneEl = paneRoot;
639      dockEl = paneRoot.querySelector('.epm-dock');
640      mobile = !!paneRoot.querySelector('.epm-col');
641      onCls  = mobile ? 'epm-on' : 'ep-on';
642      colEl   = paneRoot.querySelector(mobile ? '.epm-col' : '.ep-col');
643      boardEl = paneRoot.querySelector(mobile ? '.epm-board' : '.ep-board');
644      blockEls = [].slice.call(paneRoot.querySelectorAll('[data-ep]'));
645      if (colEl && boardEl && blockEls.length) {
646        measureCol();
647        root.classList.add('bt-colown');
648        /* the pane's beat table, for the scroll-linked column travel. Only when
649           it answers does the column stop using the CSS transition; a pane that
650           does not expose beatAt keeps round M5's step behaviour exactly. */
651        readBeats();
652        if (qNewest) root.classList.add('bt-colp');
653        curN = -2; yApplied = null;
654      } else { colEl = null; }
655    }
656
657    fit();
658    /* Bebas Neue's fallback reserves far more width, so the headline measures
659       one line taller before the webfont lands and the card would be sized for a
660       copy block it no longer has. The pane's own Inter lands on the same
661       promise, which is also when its dock settles at its final height.
662       document.fonts.ready is a promise, not a listener on document, and this is
663       the same fix the hero cycler uses. */
664    try {
665      if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
666        document.fonts.ready.then(fit);
667      }
668    } catch (e) {}
669
670    last = -1; lastQ = -1;
671    setProgress(0);
672  }
673
674  function drive(q) {
675    if (!api) return;
676    if (lastQ >= 0 && Math.abs(q - lastQ) < 0.0008) return;
677    lastQ = q;
678    try {
679      if (typeof api.setTarget === 'function') api.setTarget(q);
680      else if (typeof api.setProgress === 'function') api.setProgress(q);
681    } catch (e) {}
682    kickSettle();
683  }
684
685  var inited = false;
686  function setProgress(p) {
687    /* tolerate a host that drives before it inits */
688    if (!root && !inited) { inited = true; init(null); }
689    if (!root) return;
690    p = clamp(+p || 0);
691    if (last >= 0 && Math.abs(p - last) < 0.0004) return;
692    last = p;
693
694    /* the only thing progress moves outside the pane. A transform, never a
695       reflow, and it is over by a tenth of the beat. */
696    if (card) {
697      var t = easeOut(ramp(p, CARD_IN_A, CARD_IN_B));
698      card.style.transform = 'translate3d(0,' + (CARD_RISE * (1 - t)).toFixed(2) + 'px,0)';
699    }
700
701    drive(piecewise(MAP, p));
702    moveCol();
703  }
704
705  window.BeatTutor = {
706    init: init,
707    setProgress: setProgress,
708    /* read-only, for the harness and for anyone checking the timing */
709    paneProgress: function (p) { return piecewise(MAP, clamp(p)); }
710  };
711})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.