PageSourceSearch

https://christmascountdownshow.com/wp-content/plugins/instagram-feed-pro/js/sbi-quickscroll.js?ver=6.14.0

js christmascountdownshow.com collected 2026-10-02 05:14:06 UTC 207,353 bytes, 3,967 lines download raw bytes

1/**
2 * Swipe View — Reels Viewer (SMASH-1851)
3 *
4 * Hardened build of the "Quick Scroll" spike (`.pitch/quickscroll-reels-viewer/SPIKE.md`)
5 * against the normative Swipe View experience spec:
6 *   docs/technical-designs/swipe-view-experience-spec.md (AgDR-0087)
7 *
8 * A TikTok-style full-viewport vertical Reels viewer that opens on top of the
9 * existing feed instead of the standard Lightbox2 modal. Gated by the
10 * "Enable Swipe View for Reels" global setting (Pro-only, default ON —
11 * see sbi_quickscroll_enqueue() in inc/if-functions.php).
12 *
13 * Spec sections implemented here:
14 *   §3   Interaction model (nav thresholds, cooldowns, reduced-motion)
15 *   §4   Playback (muted autoplay, mute persistence, loop, autoplay-blocked fallback)
16 *   §5   Dead-media fallback chain (native video -> IG Reel embed -> poster+link)
17 *   §6   Chrome (counter, close, mute, play/pause, progress, caption/attribution)
18 *   §7   Accessibility (dialog semantics, focus trap+restore, live counter, scroll lock)
19 *   §8   Analytics event contract (swipeview_* — thin seam, see emitAnalytics())
20 *   §12  Security (text-not-markup, identifier validation, scheme checks)
21 *
22 * Explicitly NOT implemented here (see SMASH-1851 report for the full list):
23 *   - §4.4 exactly-three-slot windowing / neighbour preload policy — this build
24 *     keeps the spike's "render every eligible post, animate the track" model and
25 *     only lazily instantiates a slide's media on first activation. Off-screen
26 *     slides are paused, never destroyed, which satisfies that half of §4.4, but
27 *     the fixed 3-slot recycling model itself is a bigger re-architecture than
28 *     this ticket's four blockers + spec-parity list called for.
29 *   - §6.1 loading affordance while a slide hasn't reported `playing` — the spec's
30 *     own conformance table (§11) marks this "not implemented in the reference
31 *     either; first implementation is MM 1", so it is deferred, not skipped.
32 *   - The `swipeview_*` analytics events are emitted through emitAnalytics() but
33 *     have no real transport yet (SMASH-1852 owns the endpoint).
34 */
35(function ($) {
36	'use strict';
37
38	// ── State ───────────────────────────────────────────────────────────────
39	var $body, $container, $track, $gestures, $frameChrome, $top, $flash, $playState, $live,
40		$navPrev, $navNext, $muteBtn, $muteLabel, $viewPill, $progressBar, $progressFill, $coach;
41	// The rail is SESSION-LEVEL — ONE column built once, not one per slide. See
42	// buildRail() for why, and updateRail() for what changes on a slide change.
43	// Held as four handles rather than re-queried, because updateRail() runs on
44	// every navigation and these nodes never change identity.
45	var $railCol, $railLike, $railComment, $railView;
46	// The pause glyph's dissolve is driven by a class the CSS reads for exactly
47	// one 100ms window after unpausing, so the timer that removes it is state.
48	var flashOffTimer = null;
49	// Rule 1.7's "Tap to unmute" label: two timers per slide (show at +350ms,
50	// retract at 4500ms), replayed on each activation while still in the
51	// refused-muted state.
52	var soundHintInTimer = null;
53	var soundHintOutTimer = null;
54	// Rule 1.9's first-run coach overlay: one arm timer (dismiss listeners go
55	// live 350ms after open) and one auto-dismiss timer.
56	var coachArmTimer = null;
57	var coachOffTimer = null;
58	var coachArmed = false;
59	var posts = [];       // Normalised, eligible posts for the current open() session.
60	var slides = [];       // Parallel array: { post, $article, $stage, tier, inst, watchdog, playingSeen, terminalReported }
61	var currentIndex = 0;
62	var transitioning = false;
63	var navLocked = false;
64	// §4.2 revision (SMASH-1851, mirrors SMASH-1853 "sound-on-open"): records
65	// the user's last EXPLICIT choice, defaulting to "attempt unmuted" since
66	// open() and every swipe are gesture contexts where the browser permits
67	// it. The ONLY writer of `true` is an explicit mute action (toggleMute /
68	// the mute button) — a rejected attemptPlay() retry NEVER writes here
69	// (see attemptPlay()), so the next activation always retries unmuted.
70	var userMuted = false;
71	// Task B: armed by a rejected UNMUTED attemptPlay() (see
72	// handleAdapterEvent's autoplay_blocked branch). Rendered ANDed with the
73	// active slide's REAL muted state (and its mute capability) in
74	// updateMuteChrome() — see that function's docblock for why the flag
75	// alone is not sufficient.
76	var unmuteSucceeded = false;
77	var openerEl = null;          // Explicit focus-restoration target (§7.1) — never inferred from document.activeElement.
78	var touchStartY = null;
79	var touchStartTime = 0;
80	// ── Thumb-follow drag state (SMASH-1978) ────────────────────────────────
81	// `dragging` only becomes true once the finger clears DRAG_START_PX, so a
82	// tap (which reports a stray 1-2px touchmove on real hardware) never starts
83	// a drag and never calls preventDefault — that is what keeps
84	// tap-to-play/pause working on this same layer.
85	var dragging = false;
86	// The live TRACK offset in px, sign-aligned with the FINGER: positive means
87	// the finger moved down and the track moved down with it. Note this is the
88	// OPPOSITE sign to the legacy flick's `dy` (which is up-positive, see
89	// onTouchEnd) — the two are deliberately named differently because mixing
90	// them up is a silent direction inversion.
91	var dragDeltaPx = 0;
92	// The index the drag started from. Held separately from currentIndex so the
93	// base offset the delta composes over cannot move under the drag.
94	var dragBaseIndex = 0;
95	// ── Tap suppression (2026-08-27, mirrors SMASH-1853's suppressTapRef) ────
96	// A gesture that MOVED is not a tap. The gesture layer carries a real click
97	// handler for tap-to-play/pause, and a touch sequence can still synthesise a
98	// click after a drag — engines disagree about whether preventDefault() on
99	// touchmove suppresses it — so without this every swipe would also toggle
100	// playback.
101	//
102	// Written by resetDrag() and ONLY there, which is the point: four paths end a
103	// gesture here (a release, a cancel, a second finger aborting a pinch, and
104	// close() disarming a gesture still in flight), and SMASH-1853 shipped this
105	// exact defect by capturing the flag at the call sites and missing the
106	// pinch-abort. Making the capture part of ENDING a drag is what stops a fifth
107	// path from reintroducing it.
108	//
109	// It tracks `dragging` — "the finger cleared the deadzone" — rather than "a
110	// gesture ended", and that is load-bearing in the other direction too:
111	// onGestureClick() is the only reader and it clears the flag, so arming it
112	// for a contact that never became a drag would leave it set with no click
113	// coming, and swallow the visitor's NEXT genuine tap instead.
114	var suppressTap = false;
115	// ── Rule 1.7's one seam ─────────────────────────────────────────────────
116	// TRUE once this session has had an unmuted play() genuinely REFUSED by the
117	// browser. It is the whole gate on the "Tap to unmute" label: the design's
118	// affordance exists because Aman's build opens muted unconditionally, we keep
119	// §4.1's unmuted attempt (Asmita's ratified 2026-08-14 call), so the label
120	// becomes the FAILURE arm rather than the happy path.
121	//
122	// Distinct from `userMuted` on purpose. Three states have to be told apart:
123	//   refused-muted  -> paint white AND slide the label out, per slide
124	//   chosen-muted   -> paint white, NO label (respect the choice)
125	//   unmuted        -> paint dark, no label, and never label again
126	// A single boolean cannot express that, and conflating the first two is how a
127	// visitor who deliberately muted gets nagged on every swipe.
128	var autoplayRefused = false;
129	// Last string handed to the play-state live region, so an unchanged value is
130	// never re-written. See announcePlayState() — this is not a micro-optimisation.
131	var lastPlayStateText = null;
132	// Rule 1.16's wheel tuning, ported from the reference: an ACCUMULATOR plus a
133	// gesture-end gap, not a per-event threshold. See onWheel().
134	var wheelAcc = 0;
135	var wheelLastAt = 0;
136	var wheelFiredAt = 0;
137	var wheelLock = false;
138	var pollTimer = null;
139	var slidesViewed = null;      // Set of indices visited this session, for swipeview_closed's slides_viewed.
140	var suppressEmbedTier = false; // Set per-open() when the feed is GDPR-flagged and consent isn't provable (see openFromFeed()).
141	var lastAnalyticsAt = {};      // event name -> timestamp, for the §8 "one event per 250ms per type" throttle.
142
143	// ── Constants (values are normative — see the experience spec, not tuned here) ──
144	var TRANSITION_MS = 320;        // §3.2
145	// ── Wheel tuning (rule 1.16 — adopted from the reference) ───────────────
146	// The old shape fired on any single event whose |deltaY| cleared 25, then
147	// locked for 550ms. That reads a trackpad's inertia tail as fresh intent: one
148	// flick emits a long decaying burst of events, several of which clear any
149	// per-event threshold, so a flick could skip slides once the lock expired
150	// mid-tail.
151	//
152	// The replacement is the reference's, and the three numbers work together:
153	// deltas ACCUMULATE, the accumulator RESETS when the gap between events
154	// exceeds WHEEL_GAP_MS (that gap is what "the gesture ended" actually looks
155	// like), one slide fires when the accumulator clears WHEEL_THRESHOLD, and
156	// after firing nothing else fires until BOTH a real gap has been seen AND
157	// WHEEL_MIN_INTERVAL_MS has passed. So a single flick moves exactly one
158	// slide however long its tail is, and a deliberate second scroll still moves
159	// immediately once the finger lifts and returns.
160	var WHEEL_THRESHOLD = 40;
161	var WHEEL_GAP_MS = 160;
162	var WHEEL_MIN_INTERVAL_MS = 450;
163	var SWIPE_THRESHOLD_PX = 60;    // §3.1 (provisional) — now the FLICK accelerator's distance, see onTouchEnd.
164	var SWIPE_MAX_DURATION_MS = 600; // Not spec-mandated; kept from the spike as a slow-drag-vs-swipe filter.
165	// SMASH-1978: the finger must travel this far before the track starts
166	// following. Small enough to feel immediate, large enough that a tap's
167	// hardware jitter never registers as a drag (and so never preventDefaults
168	// the tap-to-play/pause gesture bound to this same layer).
169	var DRAG_START_PX = 6;
170	// SMASH-1978 area rule: on release, advance only if the incoming slide holds
171	// the MAJORITY of the viewport, i.e. the track moved more than half a slide.
172	// Expressed as a fraction so the rule reads as the area statement it is
173	// rather than as a pixel threshold.
174	var DRAG_SNAP_AREA_FRACTION = 0.5;
175	var NAV_COOLDOWN_MS = 420;      // §3.1 (provisional) — applies across ALL input sources, separate from TRANSITION_MS.
176	var START_TIMEOUT_MS = 4000;    // §5.2 start watchdog.
177	var CHROME_POLL_MS = 250;       // §6.1 "refreshed at least every 250ms".
178	var ANALYTICS_THROTTLE_MS = 250; // §8.
179	// ── Pause glyph (rule 1.10) ─────────────────────────────────────────────
180	// The glyph is now shown for AS LONG AS the slide is paused, so there is no
181	// lifetime timer for the visible state — only for the dissolve. MUST match
182	// the sbiQsFlashOut keyframe duration in css/sbi-quickscroll.css: the
183	// animation paints the dissolve and this timer removes the class that runs
184	// it, so a mismatch either clips the dissolve or leaves a transparent glyph
185	// parked over the frame. A test reads both values and asserts they agree.
186	var FLASH_OUT_MS = 100;
187	// Glyph render size inside the 84px disc: 84 minus 2 x 22px of padding.
188	// Stated here because the flash builder needs it and the padding lives in
189	// CSS; a test derives one from the other rather than trusting the pair.
190	var FLASH_GLYPH_PX = 40;
191
192	// ── "Tap to unmute" label timing (rule 1.6, measured from the reference) ──
193	var MUTE_HINT_IN_MS = 350;
194	var MUTE_HINT_OUT_MS = 4500;
195
196	// ── First-run swipe coach (rule 1.9) ────────────────────────────────────
197	// Dismiss listeners are armed 350ms AFTER open so the very tap that opened
198	// the viewer cannot dismiss the overlay before it has been read; it
199	// auto-dismisses at 3200ms regardless.
200	var COACH_ARM_MS = 350;
201	var COACH_AUTO_MS = 3200;
202	// Namespaced per plugin (rule 1.9) — the reference's bare `sc-hint-swipe`
203	// would collide with any sibling viewer sharing an origin, and a visitor who
204	// has seen TikTok's coach has not seen this one.
205	var COACH_STORAGE_KEY = 'sbi-qs-coach-swipe';
206
207	// ── Pull-to-close (rule 1.16) ───────────────────────────────────────────
208	// Only from slide 0, only at rest, only downward, and only on release. The
209	// reference's measured behaviour is 130px no / 145px yes, i.e. a strict
210	// greater-than against 140.
211	var PULL_CLOSE_PX = 140;
212	// How much of the finger's downward travel the track follows at slide 0. See
213	// clampDragDelta() for why this is damped rather than 1:1.
214	var PULL_FOLLOW_RATIO = 0.35;
215
216	// ── Sound on open — THE ONE FLAG (rule 1.7) ─────────────────────────────
217	// false = §4.1 as ratified 2026-08-14: attempt UNMUTED on activation, and
218	// the design's "Tap to unmute" label is the refusal arm.
219	// true  = the reference's model: open muted unconditionally, and the label
220	// becomes the happy-path affordance it was designed as.
221	//
222	// This is deliberately a single flag rather than two code paths, because
223	// which one is right is an open question for Asmita (log § 5 item 1, Q1 to
224	// Aman) and the answer must be one edit. Everything downstream — the initial
225	// attempt, the label gate, and whether `swipeview_autoplay_blocked` can fire
226	// at all — reads it from here.
227	var MUTED_FIRST = false;
228
229	var NO_CAPS = { mute: false, playPause: false, progress: false };
230	var FULL_CAPS = { mute: true, playPause: true, progress: true };
231
232	// ── Small helpers ───────────────────────────────────────────────────────
233	// Read TRUTHILY, and specifically not `=== false`, which is what shipped and
234	// what made this log always-on for every visitor on every customer site.
235	//
236	// The flag arrives through wp_localize_script (sbi_quickscroll_enqueue() in
237	// inc/if-functions.php), and WP_Scripts::localize() casts every scalar to a
238	// string before encoding it:
239	//
240	//     $l10n[$key] = html_entity_decode((string) $value, ENT_QUOTES, 'UTF-8');
241	//
242	// so PHP `false` becomes "" and PHP `true` becomes "1". The real payload is
243	// {"enabled":"1","debug":""} — never a boolean. `"" === false` is false, so
244	// the guard never returned and every open, swipe and close wrote console
245	// lines on production sites with WP_DEBUG off.
246	//
247	// A strict comparison against a value the transport cannot deliver fails in
248	// the worst direction available: the ONE configuration it was written for
249	// (WP_DEBUG off) is the one it never catches, and the symptom only shows up
250	// where nobody looks — a visitor's console, not a developer's. `enabled` two
251	// hundred lines below has always been read truthily against the same
252	// transport, which is why it works; this now matches it.
253	//
254	// One deliberate consequence: with sbiQuickScroll absent entirely — the
255	// body-class activation path, where nothing localizes anything — logging is
256	// now silent rather than on. Default-silent is the correct direction for code
257	// running on visitors' machines, and a developer on that path can still opt in
258	// with `window.sbiQuickScroll = { debug: true }`.
259	function log() {
260		if (!(window.sbiQuickScroll && window.sbiQuickScroll.debug)) return;
261		try { console.log.apply(console, ['[SBI Swipe View]'].concat([].slice.call(arguments))); } catch (e) {}
262	}
263
264	function prefersReducedMotion() {
265		return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
266	}
267
268	// ── Input-capability probe (unchanged mechanism, new consumers) ─────────
269	// `(hover: hover) and (pointer: fine)` — a capability query, NOT a width
270	// query. Width is the wrong signal twice over: a narrow desktop window still
271	// has a mouse and a keyboard, and a large tablet still has neither. This pair
272	// is the standard "there is a mouse-or-trackpad-class pointer here" probe.
273	//
274	// It used to gate the intro card's copy variant. That card is gone (rule 1.9
275	// — Aman's minimalism principle: "I know I added four instructions, so I'm
276	// just kind of trying to minimize what we tell them"), and the probe now
277	// gates the one hint that survives: the first-run swipe coach shows ONLY on
278	// touch, because desktop has visible chevrons and a cursor and needs no swipe
279	// instruction. The CSS makes the same distinction with the same query, which
280	// is what keeps the JS-resolved coach and the CSS-resolved layout from ever
281	// disagreeing about which device they are on.
282	function hasKeyboardAffordance() {
283		// No matchMedia at all (a very old browser): answer TRUE, i.e. treat it as
284		// a pointer device. That is the safe direction for both consumers — the
285		// coach overlay is suppressed rather than shown to someone who may have no
286		// touchscreen, and the CSS falls back to its own base (touch) layout
287		// independently, so a wrong guess here degrades one hint rather than the
288		// whole viewer.
289		if (!window.matchMedia) return true;
290		return !!window.matchMedia('(hover: hover) and (pointer: fine)').matches;
291	}
292
293	// ── Intrinsic media size — rule 1.14's fit decision ───────────────────────
294	// This function is what survives the letterbox-anchoring subsystem the intro
295	// card needed (that whole mechanism went with the card, rule 1.9): where it
296	// once measured the black band under contained media so a hint could sit on
297	// the painted frame instead of in the letterbox, it now answers the one
298	// question rule 1.14 asks — is this source wider or taller than the frame?
299	//
300	// Threshold is the reference's: strictly wider than 9/16 x 1.04 letterboxes
301	// with `contain` over a blurred poster backdrop; anything at or below it
302	// (including true 9:16, within the 4% tolerance) fills with `cover` and is
303	// cropped. The tolerance is what stops a 1080x1920 clip that reports
304	// 1080x1919 from being treated as landscape.
305	var FIT_CONTAIN_RATIO = (9 / 16) * 1.04;
306
307	function mediaIntrinsic(node) {
308		if (!node) return null;
309		var w = node.videoWidth || node.naturalWidth || 0;
310		var h = node.videoHeight || node.naturalHeight || 0;
311		return (w > 0 && h > 0) ? { w: w, h: h } : null;
312	}
313
314	// The `object-fit: contain` bottom band, in px. Reproduces the contain fit
315	// exactly: scale to the tighter axis, then the leftover height splits evenly
316	// above and below (object-position is left at its 50% 50% default, and the
317	// stylesheet never overrides it).
318
319	// Writes the fit decision onto the FRAME as a data attribute, which the
320	// stylesheet reads. Two reasons it is an attribute rather than inline style:
321	// the value is a discrete state rather than a measurement, and putting it on
322	// the frame lets one attribute drive both the media's object-fit and whether
323	// the blurred backdrop paints.
324	//
325	// Re-run rather than computed once, because intrinsic dimensions are not
326	// readable until `loadedmetadata` (video) or `load` (poster) — before that
327	// mediaIntrinsic() returns nothing and the honest answer is "not yet known",
328	// which is `cover`: the common case, and the one that never shows a black
329	// band it will later remove.
330	function applyFit(slide) {
331		if (!slide || !slide.frame) return;
332		var node = slide.stage && slide.stage.querySelector('.sbi-qs-video, .sbi-qs-poster');
333		var dims = mediaIntrinsic(node);
334		if (!dims) { slide.frame.removeAttribute('data-fit'); return; }
335		var wider = (dims.w / dims.h) > FIT_CONTAIN_RATIO;
336		if (wider) {
337			slide.frame.setAttribute('data-fit', 'contain');
338		} else {
339			slide.frame.removeAttribute('data-fit');
340		}
341		// The backdrop only earns its compositor pass when it is actually
342		// visible, and it is only visible behind contained media.
343		if (slide.backdrop) slide.backdrop.style.display = wider ? '' : 'none';
344	}
345
346	function isHttpUrl(u) {
347		return typeof u === 'string' && /^https?:\/\//i.test(u);
348	}
349
350	// Slightly more permissive variant for <video>/<img> src: same-origin relative
351	// paths are allowed (mirrors the shared reference implementation's safeMedia()),
352	// but any other scheme (javascript:, data:, blob:, etc.) is rejected outright.
353	function safeMediaSrc(u) {
354		if (typeof u !== 'string' || !u) return '';
355		if (/^https?:\/\//i.test(u)) return u;
356		return /^[a-z][a-z0-9+.\-]*:/i.test(u) ? '' : u;
357	}
358
359	// ── CSS-url guard (rule 1.14's blurred backdrop) ────────────────────────
360	// The blurred letterbox backdrop is the one place in this viewer where a
361	// post-derived string reaches CSS rather than the DOM, and CSS has its own
362	// injection surface: `url(...)` is terminated by a bare `)`, so a poster URL
363	// containing one could close the function and append arbitrary declarations
364	// to the inline style attribute.
365	//
366	// safeMediaSrc() is not sufficient by itself — it decides SCHEME, and a
367	// perfectly ordinary `https://` URL can still carry `)`, `"`, `'`, a
368	// backslash or whitespace. So this is a second, narrower gate applied on top
369	// of it, and it REJECTS rather than escapes: a poster is decoration, the
370	// fallback (a plain black frame) is already the common Instagram case
371	// because most cached rows carry no poster at all, and a rejected URL costs
372	// nothing a visitor can see. Escaping would mean trusting our own escaper on
373	// every future browser's url() parser.
374	//
375	// Control characters are excluded too — a newline inside an inline style is
376	// its own declaration separator.
377	function safeCssUrl(u) {
378		var src = safeMediaSrc(u);
379		if (!src) return '';
380		/* eslint-disable-next-line no-control-regex */
381		if (/["'()\\\s]|[\x00-\x1f]/.test(src)) return '';
382		return src;
383	}
384
385	// Instagram profile URL for a handle. Built from the handle rather than taken
386	// from the feed, because the feed emits no profile link — and built with a
387	// STRICT allowlist rather than by escaping, for the same reason safeCssUrl()
388	// rejects: Instagram handles are [A-Za-z0-9._] and at most 30 characters, so
389	// anything else is not a handle and there is nothing to salvage. This URL is
390	// the href of two controls the design adds (the @handle link and the Follow
391	// pill), so it is attacker-influenceable input on a path that used to have
392	// none.
393	var IG_HANDLE_RE = /^[A-Za-z0-9._]{1,30}$/;
394	function profileUrlForHandle(handle) {
395		if (typeof handle !== 'string' || !IG_HANDLE_RE.test(handle)) return '';
396		return 'https://www.instagram.com/' + handle + '/';
397	}
398
399	// Stable hue for the fallback avatar's gradient (rule IG-4). The reference
400	// carries an `owner.hue` in its fixture; the feed gives us no such field, so
401	// it is derived from the handle. Deterministic is the requirement, not
402	// distribution: the same account must get the same disc on every slide and
403	// every page load, or the avatar appears to change colour as the visitor
404	// swipes through one creator's reels.
405	function hueForHandle(handle) {
406		var str = typeof handle === 'string' ? handle : '';
407		var h = 0;
408		for (var i = 0; i < str.length; i++) {
409			h = ((h << 5) - h + str.charCodeAt(i)) | 0;
410		}
411		return Math.abs(h) % 360;
412	}
413
414	// Rex review (SMASH-1851): the classic lightbox's caption renderer
415	// (js/sb-instagram.js's encodeHTML(), around line 4650) unescapes a
416	// caption's literal `<br>`/`<br/>` line-break markers back into a real
417	// `<br>` tag for its own innerHTML render. This viewer never uses
418	// innerHTML for post-derived text (§12) — captionEl.textContent below
419	// would otherwise print the literal characters "<br>" instead of a line
420	// break. The safe equivalent: translate the same markers to a real `\n`
421	// character. `\n` inside a text NODE is inert — it can't be interpreted
422	// as markup — and .sbi-qs-caption's `white-space: pre-line` is what
423	// turns it into a visible line break.
424	function formatCaptionText(raw) {
425		if (typeof raw !== 'string') return '';
426		return raw.replace(/<br\s*\/?\s*>/gi, '\n');
427	}
428
429	// ── §6.1 interaction counts (SMASH-1851) ─────────────────────────────────
430	// The whole point of these two helpers is that "no data" and "zero" are
431	// DIFFERENT. Instagram returns no like/comment counts at all for personal
432	// (basic-display) connections, and the template emits an empty attribute in
433	// that case; rendering it as "0 likes" would misstate a post that may have
434	// thousands. parseCount() therefore yields null for anything that isn't a
435	// real number, and only a real number — including 0 — renders.
436	function parseCount(raw) {
437		if (raw === undefined || raw === null || raw === '') return null;
438		var n = parseInt(raw, 10);
439		return isNaN(n) || n < 0 ? null : n;
440	}
441
442	// Platform-convention abbreviation for display; the accessible name uses
443	// the exact figure instead (a screen reader gains nothing from "1.2K").
444	function formatCount(n) {
445		if (n < 1000) return String(n);
446		// 999,500 — not 1,000,000, and not 999,950 either (Rex review,
447		// 2026-08-24): the K branch rounds for k >= 100, so the SMALLEST n whose
448		// K form rounds up to 1000 (and would print "1000K") is 999,500.
449		if (n < 999500) {
450			var k = n / 1000;
451			return (k >= 100 ? Math.round(k) : Math.round(k * 10) / 10) + 'K';
452		}
453		var m = n / 1000000;
454		return (m >= 100 ? Math.round(m) : Math.round(m * 10) / 10) + 'M';
455	}
456
457	function countNoun(kind, n) {
458		if (kind === 'likes') return n === 1 ? 'like' : 'likes';
459		return n === 1 ? 'comment' : 'comments';
460	}
461
462	function exactCount(n) {
463		// Grouped with the visitor's locale, matching how the rest of the
464		// plugin renders counts in the feed itself.
465		try {
466			return n.toLocaleString();
467		} catch (e) {
468			return String(n);
469		}
470	}
471
472	// §12: "Any identifier interpolated into an embed URL MUST be validated
473	// against an expected shape before use, not concatenated raw." Instagram
474	// permalinks look like https://www.instagram.com/reel/<shortcode>/ (also
475	// /p/ and the deprecated /tv/ alias) — validate the whole shape, not just
476	// the scheme, before it becomes part of an embed src.
477	var IG_PERMALINK_RE = /^https:\/\/(?:www\.)?instagram\.com\/(?:reel|tv|p)\/[A-Za-z0-9_-]+\/?$/i;
478
479	function isValidInstagramPermalink(u) {
480		return typeof u === 'string' && IG_PERMALINK_RE.test(u);
481	}
482
483	function embedUrlForPermalink(permalink) {
484		if (!isValidInstagramPermalink(permalink)) return '';
485		var withSlash = permalink.charAt(permalink.length - 1) === '/' ? permalink : permalink + '/';
486		return withSlash + 'embed/';
487	}
488
489	function el(tag, className, attrs) {
490		var node = document.createElement(tag);
491		if (className) node.className = className;
492		if (attrs) {
493			Object.keys(attrs).forEach(function (k) { node.setAttribute(k, attrs[k]); });
494		}
495		return node;
496	}
497
498	// §8 analytics seam. No transport exists yet — SMASH-1852 owns the shared
499	// swipeview_* endpoint. This function is the ONE place that call will be
500	// added; everything else in this file only ever calls emitAnalytics().
501	// Implements the §8 throttle (>= 250ms between emits of the SAME event name)
502	// so a future transport can't be retry-stormed by fast navigation.
503	function emitAnalytics(name, payload) {
504		var now = Date.now();
505		if (lastAnalyticsAt[name] && (now - lastAnalyticsAt[name]) < ANALYTICS_THROTTLE_MS) return;
506		lastAnalyticsAt[name] = now;
507		// TODO(SMASH-1852): POST { name, payload, schema_version } to the shared
508		// swipeview analytics route once it exists. Until then this is a no-op
509		// transport with a debug log, matching §8's "a failed send is dropped
510		// silently and MUST NOT retry-storm" requirement trivially (there is no
511		// send to fail).
512		log('analytics:', name, payload);
513		try { $(document).trigger('sbi_swipeview:' + name, [payload]); } catch (e) {}
514	}
515
516	// ── Mixed-feed eligibility + dispatch guards (§2.2, §2.4) ──────────────
517
518	// A post is Reels-eligible per §2.1; the plugin's own get_media_video_type()
519	// resolution (media_product_type field, permalink /reel//tv/ fallback) is
520	// already baked into data-media-type server-side, so this is just a read.
521	function isReelAnchor($a) {
522		return ($a.attr('data-media-type') || '').toLowerCase() === 'reels';
523	}
524
525	// §2.4 lightbox-disabled suppression. `disablelightbox` has THREE
526	// divergent readers in this plugin (see the experience spec §2.4's
527	// table), so this file does NOT re-implement any part of that value-set
528	// logic. The server computes the canonical (boolean-aware, union)
529	// reading exactly once, via sbi_swipe_lightbox_disabled() in
530	// inc/if-functions.php, and rides the same data-sbi-flags channel the
531	// gdpr / overrideBlockCDN flags below already use (see
532	// feedSuppressesEmbedTier()) rather than a second channel.
533	//
534	// This is additive to, not a replacement for, the `.sbi_disable_lightbox`
535	// class check below: that class is emitted by a narrower reader
536	// (class-sbi-display-elements-pro.php:29, which accepts 'on' / 'true' /
537	// boolean true but not '1' / 1) and, where it does fire, already hides
538	// the anchor via CSS (display:none) before this file's click listener
539	// ever sees it. This flag exists to close the gap for the spellings that
540	// reader misses.
541	function feedHasLightboxDisabled($feed) {
542		var flags = (($feed.attr('data-sbi-flags') || '')).split(',');
543		return flags.indexOf('lightboxdisabled') !== -1;
544	}
545
546	// §2.4 / mixed-feed guards. All of these mirror an EXISTING signal the
547	// classic lightbox / shoppable / moderation code already relies on, so the
548	// viewer degrades exactly the same way the lightbox does for the same post:
549	//   - .sbi_link.sbi_disable_lightbox  -> "disable lightbox" setting OR an
550	//     active shoppable feed (js/sb-instagram.js adds this same class when
551	//     captionlinks/shoppable is on — see the shoppable feed link rewrite).
552	//   - .sbi_link.sbi_link_customizer   -> customizer preview context.
553	//   - .sbi.sbi_moderation_mode        -> moderation mode / customizer
554	//     moderation preview for the whole feed.
555	//   - feedHasLightboxDisabled($feed)  -> the canonical union reading of
556	//     the same "disable lightbox" setting (see above); catches the
557	//     spellings the sbi_disable_lightbox class's reader misses.
558	function isDispatchSuppressed($a, $feed) {
559		var $link = $a.closest('.sbi_link');
560		if ($link.hasClass('sbi_disable_lightbox')) return true;
561		if ($link.hasClass('sbi_link_customizer')) return true;
562		if ($feed.hasClass('sbi_moderation_mode')) return true;
563		if (feedHasLightboxDisabled($feed)) return true;
564		return false;
565	}
566
567	// §2.4 GDPR/consent. The plugin already strips media_url server-side when
568	// blocking_cdn() is true and consent hasn't been recorded at render time
569	// (SB_Instagram_GDPR_Integrations), so a post with an empty data-video on a
570	// GDPR-flagged feed is the observable signature of "not consented yet" from
571	// this file's vantage point. We do NOT have cheap read access to the live,
572	// cookie-driven `consentGiven` flag tracked inside js/sb-instagram.js's
573	// per-feed closures, so this is a conservative proxy, not a live read:
574	//   - the feed's own data-sbi-flags attribute (set once, server-side, from
575	//     SB_Instagram_GDPR_Integrations::doing_gdpr()/blocking_cdn()) tells us
576	//     whether this feed is GDPR-gated at all;
577	//   - when it is, we refuse the embed tier outright for that feed's Reels,
578	//     because a permalink-built Instagram embed loads third-party content
579	//     regardless of whether media_url survived, and permalinks are never
580	//     stripped by blocking_cdn() (they're a link out, not a CDN asset).
581	// This means a user who already consented this session sees a slightly
582	// more conservative fallback (poster+link instead of the embed tier) than
583	// is strictly necessary — an accepted, safe-direction trade documented in
584	// the SMASH-1851 report rather than a live consentGiven correlation, which
585	// would require coupling into sb-instagram.js internals this file doesn't
586	// otherwise depend on.
587	function feedSuppressesEmbedTier($feed) {
588		var flags = (($feed.attr('data-sbi-flags') || '')).split(',');
589		return flags.indexOf('gdpr') !== -1 && flags.indexOf('overrideBlockCDN') === -1;
590	}
591
592	function collectReelsFromFeed($feed) {
593		var items = [];
594		$feed.find('a.sbi_link_area[data-lightbox-sbi]').each(function () {
595			var $a = $(this);
596			if (!isReelAnchor($a)) return;
597			if (isDispatchSuppressed($a, $feed)) return;
598			items.push({
599				id: $a.attr('data-id') || '',
600				permalink: $a.attr('data-url') || '',
601				videoSrc: $a.attr('data-video') || '',
602				poster: $a.attr('href') || '',
603				caption: $a.attr('data-title') || '',
604				username: $a.attr('data-user') || '',
605				avatar: $a.attr('data-avatar') || '',
606				// §6.1 counts — null when the feed emits no data for that
607				// metric, so "missing" never renders as 0 (see parseCount).
608				likes: parseCount($a.attr('data-likes')),
609				comments: parseCount($a.attr('data-comments'))
610			});
611		});
612		return items;
613	}
614
615	// ── DOM construction (§12: text, never markup, for anything post-derived) ──
616
617	// ── Glyph provenance after the 2026-09-08 restyle ─────────────────────────
618	// THE RULE CHANGED, and it changed by explicit decision rather than drift.
619	//
620	// This file's standing rule was "glyphs are copied verbatim, never redrawn":
621	// Material Symbols geometry (Apache-2.0) for the play/pause/close class, and
622	// hand-authored speaker shapes matched to the Reels player. That rule exists
623	// because a path reproduced from memory is subtly wrong in ways only a render
624	// reveals.
625	//
626	// The reference build ships its OWN hand-drawn set — a lighter 1.8-stroke
627	// outline family for Instagram, drawn to sit together as one family rather
628	// than assembled from a licensed set. Asmita's directive ("these are the
629	// final designs and should be implemented as the design") and log rule IG-2
630	// both put that set in charge, so the design's paths REPLACE ours.
631	//
632	// The verbatim rule is not weakened, it is re-pointed: the paths below are
633	// transcribed character-for-character from Appendix A of the measured spec
634	// (local-notes/swipe-view-launch/aman-reference-final-spec-2026-09-08.md),
635	// which took them from his source rather than from a render. Nothing here is
636	// redrawn, rescaled or re-cut, and a test asserts each string appears exactly
637	// once in this file. Do not "improve" a path; change it in the design and
638	// re-transcribe.
639	//
640	// SIDE EFFECT WORTH RECORDING: this removes the last inline Material Icons
641	// path data from the viewer, which closes the open release-path licensing
642	// question the survey carried (Apache-2.0 path data inside a GPL plugin
643	// distribution). The whole glyph set is now the design's own work.
644	//
645	// One consequence for the licensing note above: the pause bars are gone
646	// entirely. Rule 1.10 replaces the play/pause tap-flash with a play triangle
647	// shown while paused, so there is no paused-state glyph to draw.
648
649	// Rail glyphs (Appendix A.2). Wrapper paint is shared: `fill: none`,
650	// `stroke: currentColor`, `stroke-width: 1.8`, round caps and joins.
651	var RAIL_ICON_PATHS = {
652		likes: ['M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z'],
653		comments: ['M12 3a9 9 0 0 0-7.6 13.8L3 21l4.4-1.3A9 9 0 1 0 12 3z'],
654		// Paper plane — two paths, one stroked line plus the closed body.
655		share: ['M22 2L11 13', 'M22 2l-7 20-4-9-9-4z'],
656		// The Instagram mark. Not a <path>
656: it is a rect plus two circles, so it
657		// is built by buildRailIcon()'s shape branch rather than declared here.
658		view: []
659	};
660	var RAIL_ICON_PAINT = {
661		fill: 'none', stroke: 'currentColor', 'stroke-width': '1.8',
662		'stroke-linecap': 'round', 'stroke-linejoin': 'round'
663	};
664
665	// The Instagram mark (Appendix A.2, `stroke-width: 2`). Declared as SHAPES
666	// rather than path data because that is what the design's source is — a
667	// rounded rect, the lens circle, and a filled flash dot. Transcribing it into
668	// a single path would be exactly the re-cutting the rule above forbids.
669	var IG_MARK_SHAPES = [
670		{ tag: 'rect', attrs: { x: '3', y: '3', width: '18', height: '18', rx: '5' } },
671		{ tag: 'circle', attrs: { cx: '12', cy: '12', r: '4' } },
672		{ tag: 'circle', attrs: { cx: '17.4', cy: '6.6', r: '1.2', fill: 'currentColor', stroke: 'none' } }
673	];
674	var IG_MARK_PAINT = {
675		fill: 'none', stroke: 'currentColor', 'stroke-width': '2',
676		'stroke-linecap': 'round', 'stroke-linejoin': 'round'
677	};
678
679	function svgRoot(size, viewBox) {
680		var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
681		svg.setAttribute('viewBox', viewBox || '0 0 24 24');
682		svg.setAttribute('width', String(size));
683		svg.setAttribute('height', String(size));
684		// Presentation only. Every control carries its own aria-label, so the
685		// glyph must stay out of the accessibility tree entirely — otherwise the
686		// control is named twice, once by the label and once by whatever the SVG
687		// contributes.
688		svg.setAttribute('aria-hidden', 'true');
689		svg.setAttribute('focusable', 'false');
690		return svg;
691	}
692
693	function svgShape(tag, attrs, paint) {
694		var node = document.createElementNS('http://www.w3.org/2000/svg', tag);
695		Object.keys(paint || {}).forEach(function (k) { node.setAttribute(k, paint[k]); });
696		Object.keys(attrs || {}).forEach(function (k) { node.setAttribute(k, attrs[k]); });
697		return node;
698	}
699
700	// `size` is the RENDER box; the 24-unit viewBox never changes, which is what
701	// makes 26px desktop / 28px mobile / 16px in the CTA pill the same ink at
702	// three scales rather than three re-cut shapes.
703	function buildRailIcon(kind, size) {
704		var svg = svgRoot(size || 26);
705		if (kind === 'view') {
706			IG_MARK_SHAPES.forEach(function (shape) {
707				svg.appendChild(svgShape(shape.tag, shape.attrs, IG_MARK_PAINT));
708			});
709			return svg;
710		}
711		(RAIL_ICON_PATHS[kind] || []).forEach(function (d) {
712			svg.appendChild(svgShape('path', { d: d }, RAIL_ICON_PAINT));
713		});
714		return svg;
715	}
716
717	// ── Verified badge (Appendix A.2) ─────────────────────────────────────────
718	// PROCEDURAL in the design's source, and procedural here for the same reason
719	// it is there: it is a 24-point star built from two alternating radii, and a
720	// literal 24-point polygon transcribed by hand is 48 numbers with no way to
721	// spot a typo by reading. Generating it from the same three constants the
722	// design uses (r = 10 / 8.4, 24 points, centre 12,12) makes the shape
723	// checkable — a test regenerates it and compares against the measured,
724	// resolved point list from the spec's appendix.
725	function verifiedPoints() {
726		var pts = [];
727		for (var i = 0; i < 24; i++) {
728			var r = (i % 2) ? 8.4 : 10;
729			var a = i * Math.PI / 12;
730			pts.push((12 + r * Math.cos(a)).toFixed(2) + ',' + (12 + r * Math.sin(a)).toFixed(2));
731		}
732		return pts.join(' ');
733	}
734
735	function buildVerifiedIcon() {
736		var svg = svgRoot(14);
737		svg.setAttribute('class', 'sbi-qs-verified');
738		// role="img" + a name, because "verified" is information rather than
739		// decoration: it is the one glyph in this file that a screen-reader user
740		// would otherwise have no way to learn.
741		svg.setAttribute('aria-hidden', 'false');
742		svg.setAttribute('role', 'img');
743		svg.setAttribute('aria-label', 'Verified');
744		svg.appendChild(svgShape('polygon', { points: verifiedPoints(), fill: '#0095f6' }, {}));
745		svg.appendChild(svgShape('path', {
746			d: 'M7.8 12.4l2.7 2.6 5.7-5.8', fill: 'none', stroke: '#fff',
747			'stroke-width': '2.2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round'
748		}, {}));
749		return svg;
750	}
751
752	// ── Control glyphs (Appendix A.1 + A.2) ───────────────────────────────────
753	// THE TABLE IS THE REVIEW SURFACE, unchanged as a principle: Aman reviews the
754	// glyph set, so a swap has to be a one-line edit here. Nothing outside this
755	// object may hard-code a path, and no call site may special-case an icon.
756	// Adding an icon means adding a key; changing one means changing its `d`.
757	//
758	// Each entry is an array so a glyph can be several paths with independent
759	// paint — the crossed speaker is a filled cone plus two stroked slashes —
760	// which is why these cannot collapse into the flat string map RAIL_ICON_PATHS
761	// uses.
762	//
763	// The viewBox stays FIXED at 24x24 whatever the render size, which is what
764	// makes the scaling uniform, and it is why every entry has to be authored on
765	// the same 24-unit grid.
766	//
767	// WHAT CHANGED IN THE RESTYLE, per glyph:
768	//   close     — was Material's 24-unit X path, filled; now the design's
769	//               2-unit STROKED cross at stroke-width 2.4 with round caps,
770	//               rendered 22px in a 40px disc (rule 1.4). The 2026-08-28
771	//               16px-render decision is reversed with it: that round was
772	//               tuning a bare glyph against a transparent disc, and rule 1.4
773	//               gives close a painted disc again.
774	//   chevrons  — was hand-authored on the 24 grid with the ink box re-derived
775	//               so each centred on (12,12); now the design's own
776	//               `M6 15l6-6 6 6` pair at stroke-width 2.2. The centring
777	//               DEFECT CLASS the old comment documented is still real and
778	//               still worth knowing (a round cap paints strokeWidth/2 past
779	//               the polyline on all four sides, and a mirrored pair spends
780	//               the error in opposite directions so the pair reads closer
781	//               together than their discs) — the reference's coordinates
782	//               happen to be symmetric about (12,12) already: ink y runs
783	//               8..16 for both, x runs 4.9..19.1 for both. A test
784	//               re-derives both boxes from the path data rather than
785	//               trusting this sentence.
786	//   play      — was Material's `M8 5v14l11-7z`; now the design's
787	//               `M8.5 5.5v13l10.5-6.5z` with a round join, which is what
788	//               gives rule 1.10's 84px glyph its softened corners.
789	//   pause     — GONE. Rule 1.10 shows a play triangle while paused rather
790	//               than echoing the tap, so no pause glyph is rendered anywhere.
791	//   volume*   — was hand-authored to the Reels player's filled speaker; now
792	//               the design's lighter stroked speaker, and the MUTED state is
793	//               a crossed-out X rather than a slash (Appendix A.2's
794	//               `ig-ic-off`). TikTok's uses a slash — the two differ ON
795	//               PURPOSE, per platform idiom, and must not be unified.
796	//   swipeHand — new (rule 1.9's coach overlay), on its own 44x64 grid.
797	var CONTROL_ICON_PATHS = {
798		close: [
799			{ d: 'M6 6l12 12M18 6L6 18', fill: 'none', stroke: 'currentColor', 'stroke-width': '2.4', 'stroke-linecap': 'round' }
800		],
801		chevronUp: [
802			{ d: 'M6 15l6-6 6 6', fill: 'none', stroke: 'currentColor', 'stroke-width': '2.2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }
803		],
804		chevronDown: [
805			{ d: 'M6 9l6 6 6-6', fill: 'none', stroke: 'currentColor', 'stroke-width': '2.2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }
806		],
807		play: [
808			{ d: 'M8.5 5.5v13l10.5-6.5z', fill: 'currentColor', stroke: 'currentColor', 'stroke-width': '2.5', 'stroke-linejoin': 'round' }
809		],
810		volumeOn: [
811			{ d: 'M11 5L6 9H2v6h4l5 4V5z', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.8', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' },
812			{ d: 'M15.5 8.5a5 5 0 0 1 0 7M18.7 5.3a9.5 9.5 0 0 1 0 13.4', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.8', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }
813		],
814		volumeOff: [
815			{ d: 'M11 5L6 9H2v6h4l5 4V5z', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.8', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' },
816			{ d: 'M22 9l-6 6M16 9l6 6', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.8', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }
817		]
818	};
819
820	// The coach overlay's hand (Appendix A.1). Its own 44x64 grid, and a circle
821	// rather than only paths, so it gets its own builder for the same reason the
822	// Instagram mark does: rescaling the design's coordinates onto the 24 grid
823	// would mean rewriting the exported geometry.
824	var SWIPE_HAND_SHAPES = [
825		{ tag: 'path', attrs: { d: 'M22 6v30' } },
826		{ tag: 'path', attrs: { d: 'M12 16l10-10 10 10' } },
827		{ tag: 'circle', attrs: { cx: '22', cy: '52', r: '6', fill: '#fff', stroke: 'none' } }
828	];
829
830	function buildSwipeHand() {
831		var svg = svgRoot(0, '0 0 44 64');
832		svg.removeAttribute('width');
833		svg.removeAttribute('height');
834		svg.setAttribute('class', 'sbi-qs-coach-hand');
835		SWIPE_HAND_SHAPES.forEach(function (shape) {
836			svg.appendChild(svgShape(shape.tag, shape.attrs, {
837				fill: 'none', stroke: '#fff', 'stroke-width': '2.4',
838				'stroke-linecap': 'round', 'stroke-linejoin': 'round'
839			}));
840		});
841		return svg;
842	}
843
844	// `size` is optional and defaults to the 22px close renders at, so a caller
845	// that does not care about scale gets the chrome default.
846	function buildControlIcon(name, size) {
847		var specs = CONTROL_ICON_PATHS[name] || [];
848		var svg = svgRoot(size || 22);
849		specs.forEach(function (spec) {
850			var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
851			// Filled by default (the historical majority case); a spec that wants
852			// stroking says so explicitly. Keys are written straight through, so
853			// a new paint attribute needs no change here.
854			path.setAttribute('fill', 'currentColor');
855			Object.keys(spec).forEach(function (k) { path.setAttribute(k, spec[k]); });
856			svg.appendChild(path);
857		});
858		return svg;
859	}
860
861	// Swaps a control's glyph in place. Clears via removeChild rather than
862	// innerHTML — §12 applies to our own markup too, and it keeps this file's
863	// "no innerHTML anywhere near a control" property greppable. Replaces the
864	// `$btn.text(glyph)` calls that used to live inline in the chrome updaters.
865	// Swaps a control's glyph in place, preserving anything else the button
866	// contains. `size` is threaded through because the mute button renders its
867	// glyph at 20px (rule 1.6) while close renders at 22px, and the swap must not
868	// silently resize the glyph it replaces.
869	//
870	// Clears the SVG children specifically rather than emptying the node, and
871	// that is a behavioural requirement rather than tidiness: the mute button now
872	// holds a `.sbi-qs-mute-label` sibling of its glyph (rule 1.6's expanding
873	// label lives INSIDE the button), and a blanket clear would delete it on
874	// every state change — taking the label, its transition and its accessible
875	// treatment with it.
876	//
877	// Removes via removeChild rather than innerHTML — §12 applies to our own
878	// markup too, and it keeps this file's "no innerHTML anywhere near a control"
879	// property greppable.
880	function setControlIcon($btn, name, size) {
881		var node = $btn && $btn[0];
882		if (!node) return;
883		var svgs = node.querySelectorAll('svg');
884		for (var i = 0; i < svgs.length; i++) node.removeChild(svgs[i]);
885		// Prepended, so the glyph stays LEFT of the label whatever order the
886		// children were in before the swap.
887		node.insertBefore(buildControlIcon(name, size), node.firstChild);
888	}
889
890	function buildContainer() {
891		if ($container && $container.length) return;
892
893		// §7.1 — advertise the shortcuts AT users cannot otherwise find.
894		// Carried on the dialog root, which is tabindex="-1", so this adds no
895		// Tab stop.
896		//
897		// Scope rule: announce a shortcut only where the action has NO reliably
898		// AT-reachable control. Deliberately narrower than onKeydown's full
899		// binding set, because a list AT reads out on every open has a cost:
900		//   Space  — togglePlay() has no focusable control AT ALL, by design
901		//            (the tap IS the control). This is the gap being closed.
902		//   Arrows — the chevrons are the only AT-reachable navigation, and
903		//            .sbi-qs-nav is display:none outside
904		//            `(hover: hover) and (pointer: fine)`, so focusables()'
905		//            offsetParent filter drops them wholesale on touch.
906		// Excluded — each action is already reachable, or is not a command:
907		//   PageUp/PageDown, J, K — exact aliases of the arrows (rule 1.17 adds
908		//            them as a superset); doubling what AT reads adds no
909		//            capability. `aria-keyshortcuts` names the CANONICAL key for
910		//            an action, not every alias bound to it.
911		//   M      — updateMuteChrome() disables .sbi-qs-mute exactly when
912		//            !caps.mute, so a labelled, enabled button is present
913		//            whenever the action is meaningful at all.
914		//   Escape — .sbi-qs-close is unconditionally in focusables(), and
915		//            Escape-dismisses is a role="dialog"/aria-modal convention
916		//            AT already conveys.
917		//   Tab    — the §7.1 focus trap, not a command.
918		// "Space" is the ARIA spelling of onKeydown's `case ' '` — the attribute
919		// is itself a space-delimited list, so the literal key value can't be
920		// used (WAI-ARIA 1.2 § aria-keyshortcuts). Every other token is its
921		// `e.key` verbatim; tests/js/swipe-key-shortcuts.test.js pins the whole
922		// value against onKeydown's real source so a token can never outlive its
923		// binding.
924		$container = $(el('div', 'sbi-qs-overlay', {
925			role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Reels viewer',
926			'aria-keyshortcuts': 'Space ArrowUp ArrowDown',
927			tabindex: '-1', 'data-muted': 'true', 'data-paused': 'false'
928		}));
929
930		// ── Window-anchored close, TOP-LEFT (rule 1.4) ──────────────────────
931		// The tooltip is one of exactly three in the design (close + both
932		// chevrons, rule 1.9) and is CSS-only: a `::after` reading the
933		// attribute, gated on pointer capability. Deliberately not an ARIA
934		// mechanism — the accessible name already says "Close", and a
935		// description repeating the shortcut that aria-keyshortcuts covers would
936		// be read twice.
937		var closeBtn = el('button', 'sbi-qs-close', {
938			type: 'button', 'aria-label': 'Close reels viewer',
939			'data-sbi-qs-tip': 'Close  Esc'
940		});
941		closeBtn.appendChild(buildControlIcon('close', 22));
942
943		var viewport = el('div', 'sbi-qs-viewport');
944		var track = el('div', 'sbi-qs-track');
945		viewport.appendChild(track);
946
947		// §7.1 "input layer" — the gesture-capture surface is a distinct element
948		// so it can be hidden from assistive tech, instead of binding
949		// wheel/touch/click handling to the dialog root itself (which DOES need
950		// to stay in the accessibility tree). Sits above the media, below the
951		// chrome (z-index).
952		//
953		// Carries NO tabindex, deliberately. It used to have tabindex="-1" to
954		// keep it "out of the tab order", which was backwards: a <div> is not
955		// focusable to begin with, so the attribute did not remove sequential
956		// focusability (there was none) — it ADDED click and programmatic
957		// focusability, which is the one thing an aria-hidden element must not
958		// have. Chrome then refused the aria-hidden outright on any click
959		// landing here: "Blocked aria-hidden on an element because its
960		// descendant retained focus". That is not a console nit — the element is
961		// un-hidden from AT for as long as it holds focus, so the layer AT is
962		// supposed to never see becomes visible to it.
963		//
964		// `inert`, which the Chrome warning suggests, is the wrong tool for THIS
965		// element: it removes pointer interaction along with AT exposure, and
966		// pointer interaction is the element's entire purpose.
967		var gestures = el('div', 'sbi-qs-gesture', { 'aria-hidden': 'true' });
968
969		// ── Session-level frame chrome (rule 1.5, 1.10, 1.12) ───────────────
970		// One overlay-level box carrying the frame's exact geometry, holding the
971		// chrome that is session-scoped rather than per-post. See its stylesheet
972		// comment for why the split exists (one mute button, one focus stop, one
973		// capability-driven disabled state) and what it costs (the frame formula
974		// is stated twice, and a test asserts the two agree).
975		var frameChrome = el('div', 'sbi-qs-framechrome');
976
977		var top = el('div', 'sbi-qs-top');
978
979		// ── Mute (rule 1.6 / spec §2.4) ─────────────────────────────────────
980		// The label is a CHILD of the button, not a sibling, and that is the
981		// whole mechanism: the design's 34 -> 136 transition is one box growing
982		// around a label sliding out of it, not two elements appearing next to
983		// each other. It is aria-hidden because the button's own aria-label
984		// already names the ACTION ("Unmute" / "Mute") — a label reading "Tap to
985		// unmute" inside a button named "Unmute" would be announced twice, and
986		// "Tap" is wrong on a keyboard anyway.
987		var muteBtn = el('button', 'sbi-qs-mute', {
988			type: 'button', 'data-act': 'mute', 'aria-label': 'Unmute', 'data-state': 'off'
989		});
990		muteBtn.appendChild(buildControlIcon('volumeOff', 20));
991		var muteLabel = el('span', 'sbi-qs-mute-label', { 'aria-hidden': 'true' });
992		muteLabel.textContent = 'Tap to unmute'; // text node — never markup (§12).
993		muteBtn.appendChild(muteLabel);
994
995		// ── CTA pill (rule IG-6) ────────────────────────────────────────────
996		// Session-level with a per-slide href, for the same reason the mute
997		// button is: there is one active post, and updateChrome() already
998		// refreshes every other session control on slide change. §6f's "the CTA
999		// is permanent chrome and MUST NOT be re-rendered or relocated as the
1000		// chain escalates" is satisfied structurally here — this element is
1001		// built once, outside the track, and no tier change can touch it.
1002		var viewPill = el('a', 'sbi-qs-view-on-ig', { target: '_blank', rel: 'noopener noreferrer' });
1003		viewPill.appendChild(buildRailIcon('view', 16));
1004		var pillLabel = document.createElement('span');
1005		pillLabel.textContent = 'View on Instagram'; // text node — never markup (§12).
1006		viewPill.appendChild(pillLabel);
1007
1008		top.appendChild(muteBtn);
1009		top.appendChild(viewPill);
1010
1011		var progress = el('div', 'sbi-qs-progress', {
1012			role: 'progressbar', 'aria-label': 'Playback progress',
1013			'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': '0'
1014		});
1015		var progressFill = el('i', 'sbi-qs-progress-fill');
1016		progress.appendChild(progressFill);
1017
1018		// ── Pause glyph (rule 1.10) ─────────────────────────────────────────
1019		// Shown for as long as the slide is paused, driven by the root's
1020		// `data-paused` attribute rather than by a class this code adds and
1021		// removes: syncPlayState() already samples the REAL player state every
1022		// 250ms, so hanging the glyph off that one attribute means the glyph
1023		// cannot disagree with the state the live region announces. A separate
1024		// class would be a second source of truth for "is it paused".
1025		//
1026		// aria-hidden because the sr-only play-state region below is what AT
1027		// consumes; a visible glyph and a live region both reporting the same
1028		// fact would double it up.
1029		var flash = el('div', 'sbi-qs-flash', { 'aria-hidden': 'true' });
1030		flash.appendChild(buildControlIcon('play', FLASH_GLYPH_PX));
1031
1032		frameChrome.appendChild(top);
1033		frameChrome.appendChild(flash);
1034		frameChrome.appendChild(progress);
1035
1036		// ── Desktop chevrons (rule 1.11) ────────────────────────────────────
1037		// Both directions are built unconditionally and DISABLED at the ends of
1038		// the track (updateChrome()), rather than being added and removed: a
1039		// control that vanishes at the last slide moves its sibling, and a
1040		// disabled button keeps the cluster's geometry stable while staying
1041		// honest about what it can do (§6.2).
1042		//
1043		// Tooltip copy names the arrow AND the new alias (rule 1.17's j/k), so
1044		// the superset is discoverable to the visitors who can use it without
1045		// putting a second token in aria-keyshortcuts for AT to read out.
1046		var nav = el('div', 'sbi-qs-nav', { 'aria-hidden': 'false' });
1047		var navPrev = el('button', 'sbi-qs-nav-prev', {
1048			type: 'button', 'data-nav': 'prev', 'aria-label': 'Previous reel',
1049			'data-sbi-qs-tip': 'Previous  ↑ / K'
1050		});
1051		navPrev.appendChild(buildControlIcon('chevronUp', 24));
1052		var navNext = el('button', 'sbi-qs-nav-next', {
1053			type: 'button', 'data-nav': 'next', 'aria-label': 'Next reel',
1054			'data-sbi-qs-tip': 'Next  ↓ / J'
1055		});
1056		navNext.appendChild(buildControlIcon('chevronDown', 24));
1057		nav.appendChild(navPrev);
1058		nav.appendChild(navNext);
1059
1060		// ── The rail, ONE per session (rule 1.3, SMASH-1851) ────────────────
1061		// An overlay-level sibling, exactly like the chevron cluster it shares
1062		// a column with — not a child of `.sbi-qs-framechrome`, which would be
1063		// the tidier-looking home and is not available: framechrome carries
1064		// `overflow: hidden` so the 2px progress bar can sit flush on the
1065		// frame's rounded bottom edge, and on desktop the rail sits OUTSIDE the
1066		// frame, so it would be clipped away entirely.
1067		//
1068		// The resting geometry is unchanged by the move, and that is checkable
1069		// rather than hopeful: `.sbi-qs-post` is `height: 100vh/100dvh` and
1070		// `.sbi-qs-overlay` is `position: fixed; inset: 0`, so the old and new
1071		// containing blocks are the same box, and the column's own insets
1072		// (`right`, `bottom`) resolve identically against either. Measured at
1073		// both viewports before and after — see the change log.
1074		var railCol = buildRail();
1075
1076		// ── Position live region (rule 1.8) ─────────────────────────────────
1077		// The visible slide counter is GONE — the design has none on any
1078		// platform or viewport, and the frame's top-left is deliberately blank
1079		// (Aman removed the wordmark and never added a counter there). §6.1's
1080		// visible-counter row is therefore an open spec amendment, not something
1081		// to reinstate here.
1082		//
1083		// §7.1's POSITION ANNOUNCEMENT is not optional though, and it is
1084		// invisible, so it survives in AT-only form: a visually-hidden polite
1085		// live region announcing "<n> of <total>" on slide change. Same
1086		// information, zero visual cost, and it preserves the blank corner the
1087		// design chose twice.
1088		var live = el('div', 'sbi-screenreader sbi-qs-live', {
1089			role: 'status', 'aria-live': 'polite', 'aria-atomic': 'true'
1090		});
1091
1092		// §6.1 "play/pause reflects actual player state" is BEHAVIOUR and stays
1093		// normative however the control is rendered. Fed from the SAME
1094		// 250ms-sampled real state the pause glyph reflects visually, so sighted
1095		// and AT users learn the same fact through different channels. Silent
1096		// when the active tier has no playPause capability (§6.2) rather than
1097		// announcing a state that isn't real.
1098		var playState = el('div', 'sbi-screenreader sbi-qs-playstate', {
1099			role: 'status', 'aria-live': 'polite', 'aria-atomic': 'true'
1100		});
1101
1102		// ── First-run swipe coach (rule 1.9) ────────────────────────────────
1103		// Built once and reused; shown by maybeShowCoach() only on touch and only
1104		// on a visitor's first ever open. aria-hidden: the swipe it teaches has
1105		// keyboard and chevron equivalents that are already announced, and a
1106		// screen-reader user is not the audience for "swipe up".
1107		var coach = el('div', 'sbi-qs-coach', { 'aria-hidden': 'true' });
1108		var coachInner = el('div', 'sbi-qs-coach-inner');
1109		var coachText = document.createElement('span');
1110		coachText.textContent = 'Swipe up for next'; // text node — never markup (§12).
1111		coachInner.appendChild(coachText);
1112		coachInner.appendChild(buildSwipeHand());
1113		coach.appendChild(coachInner);
1114
1115		// DOM order is the tab order (focusables() collects via jQuery .find(),
1116		// which returns document order — not selector order), so this sequence
1117		// is what decides the Tab cycle: mute -> CTA pill -> chevrons -> close,
1118		// then the active slide's own controls. open() focuses close, so Tab from
1119		// there lands on the first control in document order.
1120		// Appended after `nav` because the two are one visual column; the paint
1121		// order does not depend on it (the rail declares its own z-index) and
1122		// neither does the Tab cycle (focusables() composes an explicit order).
1123		$container.append(viewport, gestures, frameChrome, nav, railCol, live, playState, coach, closeBtn);
1124		$body.append($container);
1125
1126		$track = $container.find('.sbi-qs-track');
1127		$gestures = $container.find('.sbi-qs-gesture');
1128		$frameChrome = $container.find('.sbi-qs-framechrome');
1129		$top = $container.find('.sbi-qs-top');
1130		$flash = $container.find('.sbi-qs-flash');
1131		$playState = $container.find('.sbi-qs-playstate');
1132		$live = $container.find('.sbi-qs-live');
1133		$navPrev = $container.find('[data-nav="prev"]');
1134		$navNext = $container.find('[data-nav="next"]');
1135		$muteBtn = $container.find('[data-act="mute"]');
1136		$muteLabel = $container.find('.sbi-qs-mute-label');
1137		$viewPill = $container.find('.sbi-qs-view-on-ig');
1138		$progressBar = $container.find('.sbi-qs-progress');
1139		$progressFill = $container.find('.sbi-qs-progress-fill');
1140		$coach = $container.find('.sbi-qs-coach');
1141		$railCol = $container.find('.sbi-qs-railcol');
1142
1143		$container.on('click', '.sbi-qs-close', close);
1144		$muteBtn.on('click', toggleMute);
1145		// ONE handler for both chevrons, reading the direction off `data-nav`. It
1146		// routes through navigate() — the same function ArrowUp/ArrowDown call —
1147		// rather than reimplementing the step, so the cooldown, the §2.3 end
1148		// clamp, the one-slide-per-gesture rule, the stale-hint clears and the
1149		// prev/next analytics are identical to the keyboard path BY CONSTRUCTION
1150		// rather than by two code paths being kept in agreement.
1151		$navPrev.on('click', onNavClick);
1152		$navNext.on('click', onNavClick);
1153
1154
1155		// Wheel binds directly (needs passive:false to preventDefault).
1156		$gestures[0].addEventListener('wheel', onWheel, { passive: false });
1157		$gestures[0].addEventListener('touchstart', onTouchStart, { passive: true });
1158		// touchmove is NON-PASSIVE (SMASH-1978) — the same trap class as wheel
1159		// above. A passive listener's preventDefault() is ignored (with a console
1160		// warning), so the page would scroll under the viewer during a drag. The
1161		// handler only preventDefaults once a real drag is underway, so taps are
1162		// unaffected.
1163		$gestures[0].addEventListener('touchmove', onTouchMove, { passive: false });
1164		$gestures[0].addEventListener('touchend', onTouchEnd, { passive: true });
1165		$gestures[0].addEventListener('touchcancel', onTouchCancel, { passive: true });
1166		// §3.1: activating the video area toggles play/pause (NOT mute — mute is
1167		// its own explicit control, M key or the mute button). This is the ONLY
1168		// pointer path to play/pause: there is no persistent button, so the tap
1169		// IS the control rather than a convenience on top of one.
1170		//
1171		// Routed through onGestureClick(), not togglePlay() directly. A touch
1172		// sequence can still synthesise a click after a drag — engines disagree
1173		// about whether preventDefault() on touchmove suppresses it — so binding
1174		// togglePlay() here would make every swipe toggle playback.
1175		$gestures.on('click', onGestureClick);
1176	}
1177
1178	// ── Rail (rules IG-2, IG-3) ──────────────────────────────────────────────
1179	// Three items on desktop (like, comment, share) plus a fourth on touch
1180	// (view). Each has DIFFERENT semantics and a different element type, and the
1181	// differences are the point rather than an inconsistency:
1182	//
1183	//   like    — a READOUT. There is no logged-in visitor inside an embedded
1184	//             feed, so we cannot like on their behalf; a heart styled like
1185	//             Instagram's tappable heart would promise something the UI
1186	//             cannot keep. `<div role="img">` with an exact-figure label,
1187	//             `cursor: default`, hover transform suppressed in CSS.
1188	//   comment — a LINK to the post, where commenting genuinely works.
1189	//   share   — a real <button>: the only genuine in-page action here.
1190	//   view    — a LINK, and the touch counterpart of the top-row CTA pill.
1191	//
1192	// ABSENCE IS NOT ZERO, and this is where that rule is enforced: Instagram
1193	// returns no like/comment counts at all for personal (basic-display)
1194	// connections, so parseCount() yields null and the count is OMITTED. Rule
1195	// IG-3 then splits the two cases, because a missing count means different
1196	// things for the two items: a like readout with no figure is meaningless, so
1197	// the whole item is dropped; a comment glyph with no figure is still a
1198	// working link-out, so it stays.
1199	//
1200	// Instagram exposes NO SHARE COUNT at any tier, so the share glyph never
1201	// carries one (rule IG-3). TikTok's does — the two rails differ on purpose.
1202	function buildRailItem(spec) {
1203		var node = el(spec.tag, 'sbi-qs-act' + (spec.modifier ? ' ' + spec.modifier : ''), spec.attrs || {});
1204		if (spec.tag === 'button') node.type = 'button';
1205		// ── target/rel at BUILD time; href at UPDATE time ────────────────────
1206		// The rail is built once per session, so an anchor's href now arrives
1207		// later (updateRail(), per slide) while target and rel can only be set
1208		// here. They used to be written together inside `if (spec.href)`, which
1209		// was right while every item was built with its post's URL already in
1210		// hand — and became a silent security regression the moment an href was
1211		// assigned afterwards: the link would open in the SAME TAB with no
1212		// `noopener`, handing `window.opener` to instagram.com.
1213		//
1214		// An <a> with no href is neither a link nor focusable, so a slide with
1215		// no usable permalink HIDES the item outright (updateRail) rather than
1216		// leaving a bare anchor sitting in the rail.
1217		if (spec.tag === 'a') {
1218			node.target = '_blank';
1219			node.rel = 'noopener noreferrer';
1220		}
1221		if (spec.href) node.href = spec.href;
1222		node.appendChild(buildRailIcon(spec.icon, 28));
1223		// ── The count row is ALWAYS rendered, even when empty ────────────────
1224		// Measured against the reference: his like, comment and share items are
1225		// all 43px tall on desktop, despite share carrying no figure — so the
1226		// count row's 12px is reserved rather than collapsed, and the three
1227		// glyphs stay evenly pitched. Rule IG-3's "share glyph without a count"
1228		// is about the FIGURE, not about the space; collapsing the row would
1229		// pull the share glyph 17px up toward its neighbour and break the rail's
1230		// rhythm.
1231		//
1232		// The element keeps its height via a min-height in CSS, because an empty
1233		// inline element generates no line box of its own.
1234		if (spec.label) {
1235			var label = el('span', 'sbi-qs-act-label', { 'aria-hidden': 'true' });
1236			label.textContent = spec.label; // text node — never markup (§12).
1237			node.appendChild(label);
1238		} else {
1239			var count = el('b', 'sbi-qs-act-count', { 'aria-hidden': 'true' });
1240			if (spec.count !== null && spec.count !== undefined) {
1241				count.textContent = formatCount(spec.count); // text node — never markup (§12).
1242			}
1243			node.appendChild(count);
1244		}
1245		return node;
1246	}
1247
1248	// ── ONE rail, not one per slide (SMASH-1851, Asmita 2026-09-10) ──────────
1249	// Builds the column's SHELL and all three of its items once, with no post
1250	// data at all. updateRail() then re-points them on every slide change, the
1251	// same way updateChrome() has always re-pointed the top-row CTA pill.
1252	//
1253	// WHY IT MOVED OUT OF THE TRACK. The rail used to be a child of each
1254	// `.sbi-qs-post`, which is how it got its travel for free: the track's own
1255	// transform carried it. Measured on the 6.13.0.6 demo, that made the active
1256	// rail sweep the FULL SLIDE HEIGHT on every navigation — 900px at 1440x900,
1257	// 844px at 390x844 — while the top row and the chevrons held still at 0px.
1258	// The counts rode up the column with the outgoing reel and the next post's
1259	// arrived from below. Asmita's call: "keep them in place and update them
1260	// without them going up and down with the video (we do this with TikTok)."
1261	//
1262	// TikTok is the reference and its contract is precise, so it is worth
1263	// stating rather than approximating (SwipeView.jsx, `chromeTravelStyle`):
1264	//   - ONE rail element, a sibling of the track in the chrome layer.
1265	//   - It follows a FINGER DRAG by the live, end-clamped drag delta — the
1266	//     drag is direct manipulation, and a rail pinned during a drag is what
1267	//     put reel N's stats over reel N+1's incoming video (the SMASH-1978
1268	//     "the rail travels with the reel" call, 2026-08-27, which this does
1269	//     NOT reverse).
1270	//   - On a COMMIT it "lands at rest in the same paint that swaps its
1271	//     content" while the track animates the rest of the distance. So a
1272	//     wheel, key, chevron or drag-commit navigation moves it not at all.
1273	// That split is the whole change here: the drag-follow is preserved, the
1274	// commit-travel is removed. See setRailTravel() for the mechanism.
1275	//
1276	// Every item exists for the whole session and is shown or hidden per slide,
1277	// rather than being created and destroyed. That is what makes the four
1278	// contracts this rail already carried survive the move by CONSTRUCTION
1279	// rather than by re-derivation: one stable set of 44px hit rings, one
1280	// bounding box for the dead-space carve-out, one focus stop per control
1281	// instead of N, and no post captured in a closure anywhere — every href and
1282	// figure is read from `slides[currentIndex]` at update time.
1283	//
1284	// The ITEM SET still varies per post, because rule IG-3's "absence is not
1285	// zero" is unchanged: Instagram returns no counts at all for a personal
1286	// (basic-display) connection. The rail is bottom-anchored
1287	// (`justify-content: flex-end`), so losing an item shortens the column
1288	// upward and moves nothing that is left — the property
1289	// swipe-chrome-hit-test.test.js already derives 122px/193px from.
1290	//
1291	//   like    — a READOUT. There is no logged-in visitor inside an embedded
1292	//             feed, so we cannot like on their behalf; a heart styled like
1293	//             Instagram's tappable heart would promise something the UI
1294	//             cannot keep. `<div role="img">` with an exact-figure label,
1295	//             `cursor: default`, hover transform suppressed in CSS.
1296	//   comment — a LINK to the post, where commenting genuinely works.
1297	//   view    — a LINK, and the touch counterpart of the top-row CTA pill.
1298	//             `display: none` on desktop, where the pill takes over.
1299	//
1300	// ── NO SHARE ITEM — Asmita's call, 2026-09-08 ───────────────────────────
1301	// This overrides decision-log rule IG-3, which specified a share button
1302	// wired to Web Share. Her rule for the rail is PARITY with what the plugin
1303	// already shows elsewhere: "do not miss interaction counters displayed on
1304	// other layouts on the Swipe layout", and conversely do not invent one the
1305	// plugin does not have.
1306	//
1307	// The audit (see the change log's parity table) is unambiguous. Nothing in
1308	// Instagram Feed Pro renders a share COUNT anywhere — not the hover overlay
1309	// (hover-likes.php / hover-comments.php are the only two count templates),
1310	// not the lightbox, not any other template — because the Instagram Graph
1311	// API returns no share count for media. The lightbox does have an
1312	// `sbi_share` action, but it is a SOCIAL SHARING MENU (Facebook / X /
1313	// LinkedIn / Pinterest / email) with no number attached, so it is not a
1314	// counter and not this rail's business. A share glyph here was the one rail
1315	// item with no counterpart anywhere else in the plugin, and the only one
1316	// that could never carry a figure. It goes.
1317	//
1318	// Instagram exposes NO SHARE COUNT at any tier (rule IG-3). TikTok's does —
1319	// the two rails differ on purpose.
1320	function buildRail() {
1321		var col = el('div', 'sbi-qs-railcol');
1322		var rail = el('div', 'sbi-qs-rail');
1323
1324		// The aria-labels here are PLACEHOLDERS that updateRail() overwrites
1325		// before the column is ever shown, and the like readout's is empty on
1326		// purpose: a stale figure is worse than none, and this node is hidden
1327		// until a post with a figure makes it visible.
1328		$railLike = $(buildRailItem({
1329			tag: 'div', icon: 'likes', count: null,
1330			modifier: 'sbi-qs-act-like',
1331			attrs: { role: 'img', 'aria-label': '' }
1332		}));
1333		$railComment = $(buildRailItem({
1334			tag: 'a', icon: 'comments', count: null,
1335			attrs: { 'aria-label': 'View comments on Instagram' }
1336		}));
1337		$railView = $(buildRailItem({
1338			tag: 'a', icon: 'view', count: null, label: 'View',
1339			modifier: 'sbi-qs-act-view',
1340			attrs: { 'aria-label': 'View on Instagram' }
1341		}));
1342
1343		// Document order IS the rail's visual order (a flex column), and it is
1344		// the reference's anatomy: like -> comment -> view.
1345		rail.appendChild($railLike[0]);
1346		rail.appendChild($railComment[0]);
1347		rail.appendChild($railView[0]);
1348		col.appendChild(rail);
1349		// HIDDEN until a post makes them visible, so the invariant "the rail
1350		// never shows an item with no figure and no href" is a property of this
1351		// function rather than a consequence of open()'s call order. open() does
1352		// reach updateRail() (via goToIndex) before the overlay fades in, so
1353		// nothing would flash today — but that is one reordering away from a
1354		// rail of empty glyphs appearing for a frame.
1355		$railLike[0].hidden = true;
1356		$railComment[0].hidden = true;
1357		$railView[0].hidden = true;
1358		col.hidden = true;
1359		return col;
1360	}
1361
1362	// ── The rail's per-slide content (rules IG-2, IG-3) ──────────────────────
1363	// Called from updateChrome(), which is the ONE function that means "the
1364	// active post changed" — the same call that re-points the top-row CTA pill.
1365	// That is deliberately the commit moment and not a moment during the
1366	// transition: TikTok swaps its counts in the paint the commit lands in, and
1367	// a mid-transition swap would show the incoming post's figures over the
1368	// outgoing post's video.
1369	//
1370	// Everything is read from the `post` handed in, so no item can ever hold a
1371	// stale identity: there is no closure over a post anywhere in the rail, and
1372	// an href is only ever the ACTIVE post's permalink. This is what keeps a
1373	// future analytics emission honest too — §8's seam has no rail event yet
1374	// (SMASH-1852 owns the transport), and when one lands it reads
1375	// `currentIndex` like every other emission in this file rather than a value
1376	// captured when the rail was built.
1377	function updateRail(post) {
1378		if (!$railCol || !$railCol.length) return;
1379
1380		var safeHref = (post && isHttpUrl(post.permalink)) ? post.permalink : '';
1381		var likes = post ? post.likes : null;
1382		var comments = post ? post.comments : null;
1383
1384		// Rule IG-3, half one: a like readout with no figure is meaningless, so
1385		// the whole item goes. `absence is not zero` — a non-business account
1386		// reports no metrics at all, and rendering "0 likes" would misstate it.
1387		var hasLikes = likes !== null && likes !== undefined;
1388		setRailItem($railLike, hasLikes, {
1389			count: hasLikes ? formatCount(likes) : '',
1390			// Singular/plural for the spoken form only — "1 likes" read aloud
1391			// is sloppy. The glyph and the abbreviated figure are decorative
1392			// duplicates of this label, so both are aria-hidden.
1393			label: hasLikes ? exactCount(likes) + ' ' + countNoun('likes', likes) : ''
1394		});
1395
1396		// Rule IG-3, half two: the comment item is gated on the HREF, not on
1397		// the count — a comment glyph with no figure is still a working
1398		// link-out to where commenting genuinely happens.
1399		var hasComments = comments !== null && comments !== undefined;
1400		setRailItem($railComment, !!safeHref, {
1401			href: safeHref,
1402			count: hasComments ? formatCount(comments) : '',
1403			label: hasComments
1404				? exactCount(comments) + ' ' + countNoun('comments', comments) + ' — view on Instagram'
1405				: 'View comments on Instagram'
1406		});
1407
1408		// Touch only — CSS hides it under the pointer-capability query, where
1409		// the top-row pill takes over. Rule IG-6, and Aman states the migration
1410		// explicitly: "the view button on mobile moves at the bottom, the mute
1411		// stays up top".
1412		setRailItem($railView, !!safeHref, { href: safeHref, label: 'View on Instagram' });
1413
1414		// ── An all-empty rail hides the COLUMN, not just its items ───────────
1415		// This is the session-level successor to buildRail()'s old
1416		// `if (!rail.firstChild) return null`, and it has to hide the column
1417		// rather than merely empty it. The column's own 18px of bottom padding
1418		// is dead space for BOTH the dismiss and the tap-to-pause carve-outs
1419		// (pointIsInControlDeadSpace), so an empty-but-laid-out column would
1420		// keep carving a live region out of the backdrop for a post that has no
1421		// rail at all. A `hidden` column reports a 0x0 rect, which rectHasArea()
1422		// rejects — so the carve-out self-disables exactly where the rail does,
1423		// which is what swipe-backdrop-close.test.js's empty-column case pins.
1424		var anyVisible = !$railLike[0].hidden || !$railComment[0].hidden || !$railView[0].hidden;
1425		$railCol[0].hidden = !anyVisible;
1426	}
1427
1428	// One item's per-slide state. `hidden` is the right primitive for the DATA
1429	// condition (this post has no figure / no permalink) and CSS owns the
1430	// VIEWPORT condition (`.sbi-qs-act-view` is display:none on desktop) — the
1431	// two are independent and must not be folded into one mechanism.
1432	//
1433	// The stylesheet carries an explicit `.sbi-qs-act[hidden] { display: none }`
1434	// because `hidden`'s UA rule is the weakest possible `display: none` and
1435	// `.sbi-qs-act` sets `display: flex`: an AUTHOR declaration beats it, so the
1436	// attribute alone would hide nothing at all. That is a genuinely
1437	// easy-to-miss trap, which is why the rule and this comment both exist.
1438	function setRailItem($item, visible, spec) {
1439		var node = $item && $item[0];
1440		if (!node) return;
1441		node.hidden = !visible;
1442		if (spec.href !== undefined) {
1443			// Only ever the ACTIVE post's permalink, and only on a visible item
1444			// — the item is hidden whenever there is no safe href, so an anchor
1445			// is never left holding a previous slide's URL. Removing it (rather
1446			// than writing '') is what keeps a hidden anchor a non-link.
1447			if (spec.href) node.href = spec.href;
1448			else node.removeAttribute('href');
1449		}
1450		if (spec.label !== undefined) node.setAttribute('aria-label', spec.label);
1451		if (spec.count !== undefined) {
1452			var countNode = node.querySelector('.sbi-qs-act-count');
1453			// text node — never markup (§12).
1454			if (countNode) countNode.textContent = spec.count;
1455		}
1456	}
1457
1458	// ── The rail's drag-follow (SMASH-1851 / SMASH-1978) ─────────────────────
1459	// The rail mirrors the track's live drag delta and nothing else. `pxDelta`
1460	// is `dragDeltaPx` verbatim — the SAME already-end-clamped value the track
1461	// composes, deliberately shared rather than recomputed, because a second
1462	// derivation is how the two would eventually disagree about a clamp and
1463	// drift apart by a few pixels at the ends of the track.
1464	//
1465	// The delta alone is the correct mirror, not the track's whole transform.
1466	// The track sits at `-index * 100vh + dragDeltaPx`; its active slide
1467	// therefore sits at `dragDeltaPx` relative to the frame, and that is what
1468	// this rail — which describes exactly that one slide — has to match.
1469	//
1470	// `animate` is true for exactly one caller: settleBack(), the animated
1471	// return after a drag that did NOT commit. It is false during the drag (any
1472	// transition would make the rail lag the finger) and false on a commit (the
1473	// rail lands at rest in the same paint updateRail() swaps its content in).
1474	// Under reduced motion the FOLLOW stays and only the snap goes instant —
1475	// §3.2 is about motion the visitor did not ask for, and dragging is direct
1476	// manipulation they are performing themselves.
1477	function setRailTravel(pxDelta, animate) {
1478		var node = $railCol && $railCol[0];
1479		if (!node) return;
1480		// A LOCAL, not module state. The offset is write-only from this
1481		// function's point of view and nothing else in the file reads 
1481it — the
1482		// carve-out measures the rail's real rect instead
1483		// (pointIsInControlDeadSpace), which already reflects the transform and
1484		// cannot drift from it the way a cached number could.
1485		var px = pxDelta || 0;
1486		var instant = !animate || prefersReducedMotion();
1487		node.style.transitionDuration = instant ? '0ms' : TRANSITION_MS + 'ms';
1488		// Cleared to '' rather than to `translateY(0px)` so a rail at rest
1489		// carries no transform at all, and therefore establishes no containing
1490		// block and no stacking context it does not need.
1491		node.style.transform = px ? 'translateY(' + px + 'px)' : '';
1492	}
1493
1494	// ── Caption (rules IG-4, IG-5) ───────────────────────────────────────────
1495	// Hashtags are tokenised out of the caption so they can take the design's
1496	// colour, and they are built as INERT SPANS rather than links. That is a
1497	// deliberate, escalated conflict rather than an oversight — see the
1498	// stylesheet's .sbi-qs-tag comment: rule IG-5 asks for both "caption text
1499	// never tappable" and "hashtags, hover underline", and spec §6.1's expander
1500	// contract (which encodes a defect that shipped three times) settles it
1501	// toward the first.
1502	//
1503	// Tokenising is done over the already-plain caption STRING and emits text
1504	// nodes and elements — at no point does post-derived text pass through
1505	// innerHTML (§12).
1506	var CAPTION_TOKEN_RE = /#[\wÀ-ɏЀ-ӿ]+/g;
1507	function appendCaptionText(target, raw) {
1508		var text = formatCaptionText(raw);
1509		var last = 0;
1510		var m;
1511		CAPTION_TOKEN_RE.lastIndex = 0;
1512		while ((m = CAPTION_TOKEN_RE.exec(text)) !== null) {
1513			if (m.index > last) target.appendChild(document.createTextNode(text.slice(last, m.index)));
1514			var tag = el('span', 'sbi-qs-tag');
1515			tag.textContent = m[0]; // text node — never markup (§12).
1516			target.appendChild(tag);
1517			last = m.index + m[0].length;
1518		}
1519		if (last < text.length) target.appendChild(document.createTextNode(text.slice(last)));
1520	}
1521
1522	// Builds everything that lives INSIDE the painted frame for one slide, plus
1523	// the rail beside it. The media element itself is not built here — it arrives
1524	// later, per tier, into the media host (see instantiateTier(), which clears
1525	// only that host so this chrome survives every escalation).
1526	function buildSlideChrome(post, frame) {
1527		// The blurred letterbox backdrop (rule 1.14). Built only when a poster
1528		// URL survives BOTH the scheme check and the CSS-url guard, and hidden
1529		// until applyFit() decides the media is actually wider than the frame.
1530		// Instagram posters are absent on most cached rows, so the plain black
1531		// frame is the common IG case.
1532		var posterUrl = safeCssUrl(post.poster);
1533		var backdrop = null;
1534		if (posterUrl) {
1535			backdrop = el('div', 'sbi-qs-backdrop', { 'aria-hidden': 'true' });
1536			backdrop.style.backgroundImage = 'url("' + posterUrl + '")';
1537			backdrop.style.display = 'none';
1538			frame.appendChild(backdrop);
1539		}
1540
1541		// The tier's own element goes in here. Separate from the frame so
1542		// instantiateTier()'s teardown cannot take the chrome with it — the old
1543		// structure had the media and the frame as one element, which is why the
1544		// chrome had to live outside and hug it.
1545		var host = el('div', 'sbi-qs-mediahost');
1546		frame.appendChild(host);
1547
1548		// §6.1's loading affordance: never a blank frame for an activated slide
1549		// that is not yet playing (rule 1.19).
1550		frame.appendChild(el('div', 'sbi-qs-spinner', { 'aria-hidden': 'true' }));
1551
1552		// Terminal tier (rule 1.19). Present on every slide but only displayed
1553		// when the frame carries data-tier="terminal", so escalating to it is an
1554		// attribute write rather than a DOM build — which is what keeps §6f's
1555		// "the CTA MUST NOT be re-rendered as the chain escalates" true by
1556		// construction.
1557		var fail = el('div', 'sbi-qs-fail');
1558		var failText = el('span', 'sbi-qs-fail-text');
1559		failText.textContent = 'Video unavailable'; // text node — never markup (§12).
1560		fail.appendChild(failText);
1561		var failHref = isHttpUrl(post.permalink) ? post.permalink : '';
1562		if (failHref) {
1563			// Styled exactly like the top-row CTA pill (rule 1.19), but on its
1564			// OWN class — the stylesheet lists the two selectors in one rule, so
1565			// there is still a single source of truth for the paint.
1566			//
1567			// Deliberately NOT reusing .sbi-qs-view-on-ig, and the reason is a
1568			// footgun rather than a preference: this element is per-slide and
1569			// display:none until its tier is reached, while the CTA is
1570			// session-level and always present. Sharing the class means a
1571			// document-order `querySelector('.sbi-qs-view-on-ig')` returns
1572			// whichever comes first in the DOM — which is this one, inside the
1573			// track — so any future code (or test, or measurement probe) that
1574			// reaches for "the CTA pill" by class silently gets a zero-box
1575			// element instead. Caught by the browser measurement pass.
1576			var failLink = el('a', 'sbi-qs-fail-link', {
1577				target: '_blank', rel: 'noopener noreferrer'
1578			});
1579			failLink.href = failHref;
1580			failLink.appendChild(buildRailIcon('view', 16));
1581			var failLabel = document.createElement('span');
1582			failLabel.textContent = 'Open on Instagram'; // text node — never markup (§12).
1583			failLink.appendChild(failLabel);
1584			fail.appendChild(failLink);
1585		}
1586		frame.appendChild(fail);
1587
1588		frame.appendChild(el('div', 'sbi-qs-scrim', { 'aria-hidden': 'true' }));
1589
1590		// ── Bottom row (rule IG-4) ──────────────────────────────────────────
1591		var bottom = el('div', 'sbi-qs-bottom');
1592		var profileUrl = profileUrlForHandle(post.username);
1593
1594		if (post.username || post.avatar) {
1595			var row = el('div', 'sbi-qs-authorrow');
1596
1597			// The @handle is a LINK to the profile (rule IG-4) — new
1598			// attacker-influenceable href surface, which is why
1599			// profileUrlForHandle() allowlists rather than escapes. With no safe
1600			// handle the row degrades to a plain <div>: the avatar and handle
1601			// still render, they simply do not link anywhere.
1602			var author = el(profileUrl ? 'a' : 'div', 'sbi-qs-author');
1603			if (profileUrl) {
1604				author.href = profileUrl;
1605				author.target = '_blank';
1606				author.rel = 'noopener noreferrer';
1607				author.setAttribute('aria-label', '@' + post.username + ' on Instagram');
1608			}
1609
1610			var safeAvatar = safeMediaSrc(post.avatar);
1611			if (safeAvatar) {
1612				var avatarImg = el('img', 'sbi-qs-avatar', { alt: '' });
1613				avatarImg.src = safeAvatar; // property assignment, not string concat into markup.
1614				// A dead avatar URL must not leave a grey box where the design
1615				// has a disc: swap in the same initial-letter fallback the
1616				// no-URL case uses.
1617				avatarImg.addEventListener('error', function () {
1618					var repl = buildAvatarFallback(post.username);
1619					if (avatarImg.parentNode) avatarImg.parentNode.replaceChild(repl, avatarImg);
1620				});
1621				author.appendChild(avatarImg);
1622			} else {
1623				author.appendChild(buildAvatarFallback(post.username));
1624			}
1625
1626			if (post.username) {
1627				var handle = el('span', 'sbi-qs-handle');
1628				var handleText = el('span', 'sbi-qs-handle-text');
1629				handleText.textContent = '@' + post.username; // text node — never markup (§12).
1630				handle.appendChild(handleText);
1631				// ── Verified badge: rule IG-4's "when known", and it is NEVER
1632				// known on this feed ─────────────────────────────────────────
1633				// The design shows a verified tick beside the handle. The
1634				// Instagram feed payload carries no verified flag at all
1635				// (templates/elements/item/hover-item.php emits data-user,
1636				// data-avatar, data-url, data-likes, data-comments and nothing
1637				// else), and neither does the cached row it is built from — so
1638				// this branch cannot fire today. It is written, tested and left
1639				// in place rather than dropped, because "when known" is the
1640				// rule and the day the field exists this is one emitter away
1641				// from working. Recorded in the change log as a gap, not a
1642				// silent omission.
1643				if (post.verified) handle.appendChild(buildVerifiedIcon());
1644				author.appendChild(handle);
1645			}
1646			row.appendChild(author);
1647
1648			// Follow (rule IG-4). Aman's stated rationale is CTA density — "our
1649			// users might appreciate having CTA almost everywhere" — which is
1650			// why this stacks up to three outbound CTAs per slide on desktop
1651			// alongside the top pill and the rail's comment link. That is
1652			// deliberate; do not thin it out without his sign-off.
1653			if (profileUrl) {
1654				var follow = el('a', 'sbi-qs-follow', {
1655					target: '_blank', rel: 'noopener noreferrer',
1656					'aria-label': 'Follow @' + post.username + ' on Instagram'
1657				});
1658				follow.href = profileUrl;
1659				follow.textContent = 'Follow'; // text node — never markup (§12).
1660				row.appendChild(follow);
1661			}
1662			bottom.appendChild(row);
1663		}
1664
1665		if (post.caption) {
1666			var caption = el('p', 'sbi-qs-caption');
1667			var captionText = el('span', 'sbi-qs-caption-text');
1668			appendCaptionText(captionText, post.caption);
1669			caption.appendChild(captionText);
1670			// The ONLY interactive piece (§6.1, rule IG-5). Revealed by
1671			// syncCaptionExpansion() on MEASURED overflow, because the one-line
1672			// clamp's geometry depends on viewport and font — the same reasoning
1673			// §6.3 applies to collision.
1674			var moreBtn = el('button', 'sbi-qs-caption-more', { type: 'button', 'aria-expanded': 'false' });
1675			moreBtn.textContent = 'more'; // text node — never markup (§12).
1676			moreBtn.addEventListener('click', function () {
1677				var expanded = caption.classList.toggle('sbi-qs-caption-expanded');
1678				moreBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
1679				moreBtn.textContent = expanded ? 'less' : 'more';
1680				if (!expanded) captionText.scrollTop = 0;
1681			});
1682			caption.appendChild(moreBtn);
1683			bottom.appendChild(caption);
1684		}
1685
1686		frame.appendChild(bottom);
1687
1688		// No `rail` any more — it is session-level and built once by
1689		// buildContainer(). See buildRail().
1690		return { host: host, backdrop: backdrop };
1691	}
1692
1693	// The initial-letter gradient disc (rule IG-4). Hue is derived from the
1694	// handle so one creator's disc is the same colour on every slide.
1695	function buildAvatarFallback(username) {
1696		var node = el('span', 'sbi-qs-avatar sbi-qs-avatar-fallback', { 'aria-hidden': 'true' });
1697		var name = typeof username === 'string' ? username : '';
1698		node.style.setProperty('--sbi-qs-avatar-hue', String(hueForHandle(name)));
1699		node.textContent = name ? name.charAt(0) : ''; // text node — never markup (§12).
1700		return node;
1701	}
1702
1703	function renderPosts() {
1704		$track.empty();
1705		slides = [];
1706
1707		posts.forEach(function (post, i) {
1708			var article = el('article', 'sbi-qs-post', {
1709				'data-index': String(i),
1710				'aria-roledescription': 'reel',
1711				'aria-label': 'Reel ' + (i + 1) + ' of ' + posts.length
1712			});
1713			// The PAINTED FRAME (rule 1.1). All per-slide chrome now lives inside
1714			// it — the old structure had the chrome hugging the stage from
1715			// outside, which is what made the meta row read wider than the
1716			// video column (the mixed-anchoring advisory the survey carried and
1717			// Aman flagged for the design pass). Everything in the frame is
1718			// frame-anchored now; only close is window-anchored.
1719			var frame = el('div', 'sbi-qs-media');
1720			article.appendChild(frame);
1721			var built = buildSlideChrome(post, frame);
1722			// NO RAIL HERE (SMASH-1851). It used to be appended to the article,
1723			// which is what made it travel the full slide height on every
1724			// navigation; it is now one session-level column outside the track,
1725			// re-pointed by updateRail(). Anything inside the track inherits
1726			// the track's transform, so a slide is the one place the rail
1727			// cannot live if it is to stay put.
1728			$track[0].appendChild(article);
1729
1730			slides.push({
1731				post: post,
1732				article: article,
1733				// `stage` is the MEDIA HOST, not the frame: instantiateTier()
1734				// clears it on every escalation, and the chrome must survive
1735				// that. `frame` is the painted box the chrome and the fit /
1736				// buffering / tier attributes hang off.
1737				stage: built.host,
1738				frame: frame,
1739				backdrop: built.backdrop,
1740				tier: -1,          // Not yet instantiated (lazy — see ensureSlideMedia()).
1741				inst: null,
1742				caps: NO_CAPS,
1743				watchdog: null,
1744				playingSeen: false,
1745				terminalReported: false,
1746				// SMASH-1979 (§4.4 adjacent pre-warm): true only while this slide's
1747				// instance exists BECAUSE pre-warm created it and activation has not
1748				// yet taken over. It is the single discriminator for three rules that
1749				// all key off "this media exists speculatively": emissions are
1750				// discarded (§9.1 interim contract), activation re-evaluates the chain
1751				// from tier 1 rather than inheriting the element (§4.4 rule 2), and
1752				// leaving the ±1 window releases it (§4.4's lifecycle half).
1753				// Cleared the instant activation claims the slide, so an ACTIVATED
1754				// slide is never touched by any of the three.
1755				warm: false
1756			});
1757		});
1758	}
1759
1760	// ── Fallback chain (§5) ─────────────────────────────────────────────────
1761	// Tier 0: native <video> on the cached media_url.
1762	// Tier 1: Instagram Reel embed iframe (only offered with a validated permalink
1763	//         AND when the feed isn't GDPR-suppressed for embeds — see
1764	//         feedSuppressesEmbedTier()).
1765	// Tier 2 (terminal): poster image in the stage + the always-present "View on
1766	//         Instagram" CTA already built into the slide's chrome.
1767
1768	function tierCanPlay(tierIndex, post) {
1769		if (tierIndex === 0) return !!safeMediaSrc(post.videoSrc);
1770		if (tierIndex === 1) return !suppressEmbedTier && !!embedUrlForPermalink(post.permalink);
1771		return true; // terminal tier always accepts.
1772	}
1773
1774	function nextChainIndex(fromIndex, post) {
1775		for (var i = fromIndex; i <= 2; i++) {
1776			if (tierCanPlay(i, post)) return i;
1777		}
1778		return 2; // Unreachable in practice — terminal always accepts — but stay safe.
1779	}
1780
1781	function clearWatchdog(slide) {
1782		if (slide.watchdog) { clearTimeout(slide.watchdog); slide.watchdog = null; }
1783	}
1784
1785	function armWatchdog(slide, index) {
1786		clearWatchdog(slide);
1787		if (slide.playingSeen) return;
1788		slide.watchdog = setTimeout(function () {
1789			if (index !== currentIndex || slide.playingSeen) return;
1790			var terminal = slide.tier >= 2;
1791			emitAnalytics('swipeview_playback_stalled', { index: index, adapter: tierName(slide.tier), terminal: terminal });
1792			if (!terminal) {
1793				handleAdapterEvent(slide, index, 'error', { reason: 'no-start' });
1794			}
1795		}, START_TIMEOUT_MS);
1796	}
1797
1798	function tierName(tierIndex) {
1799		return tierIndex === 0 ? 'native-video' : (tierIndex === 1 ? 'ig-reel-embed' : 'poster-link');
1800	}
1801
1802	// Tears down whatever is currently in a slide's stage and instantiates the
1803	// next viable tier starting at `fromTier`. Mirrors the shared reference
1804	// shell's _instantiate(): destroy before clearing the stage so a <video>
1805	// doesn't keep buffering in a detached node.
1806	function instantiateTier(slide, index, fromTier) {
1807		clearWatchdog(slide);
1808		slide.playingSeen = false;
1809		if (slide.inst && slide.inst.destroy) slide.inst.destroy();
1810		slide.inst = null;
1811		slide.stage.innerHTML = '';
1812
1813		var tier = nextChainIndex(fromTier, slide.post);
1814		slide.tier = tier;
1815		// Drives the terminal-tier presentation (rule 1.19) from an attribute
1816		// rather than by building or moving DOM, so the permanent CTA is
1817		// untouched by an escalation (§6f).
1818		if (slide.frame) {
1819			slide.frame.setAttribute('data-tier', tier >= 2 ? 'terminal' : tierName(tier));
1820			// A new tier means new (or no) intrinsic dimensions; the previous
1821			// tier's answer must not survive into it.
1822			slide.frame.removeAttribute('data-fit');
1823			slide.frame.removeAttribute('data-buffering');
1824		}
1825
1826		if (tier === 0) {
1827			slide.caps = FULL_CAPS;
1828			slide.inst = instantiateNativeVideo(slide, index);
1829		} else if (tier === 1) {
1830			slide.caps = NO_CAPS;
1831			slide.inst = instantiateEmbed(slide, index);
1832		} else {
1833			slide.caps = NO_CAPS;
1834			slide.inst = instantiatePosterLink(slide, index);
1835			if (!slide.terminalReported) {
1836				slide.terminalReported = true;
1837				emitAnalytics('swipeview_terminal_tier', { index: index, adapter: 'poster-link' });
1838			}
1839		}
1840	}
1841
1842	function handleAdapterEvent(slide, index, kind, detail) {
1843		// ── §9.1 INTERIM CONTRACT: consumer-side emission discard (SMASH-1979) ──
1844		//
1845		// Pre-warm has no warm-only instantiation path to use: `create(post, ctx)`
1846		// is the seam's ONLY entry point and it wires `ctx.emit` unconditionally, so
1847		// a pre-warmed <video> arrives fully connected to this function — its
1848		// `error`, `playing` and `ended` listeners all land here exactly as an active
1849		// slide's do. §4.4 therefore mandates the consumer side of the fix:
1850		// "Adapter emissions (§9.1) from a slide that is not active MUST be
1851		// discarded." This is that discard, and it is deliberately the FIRST thing
1852		// this function does — every branch below either emits §8 analytics or
1853		// escalates the chain, and both are forbidden for a pre-warm:
1854		//
1855		//   "a pre-warm error MUST NOT escalate the chain, MUST NOT pin or skip a
1856		//    tier, and MUST NOT emit swipeview_playback_fallback or any other §8
1857		//    event."  (§4.4 rule 1)
1858		//
1859		// Why that matters beyond correctness: a leaked pre-warm failure corrupts the
1860		// two numbers §11 leans on hardest — the fallback rate and
1861		// swipeview_terminal_tier — by counting failures no visitor ever saw. A
1862		// signed IG media URL that has expired (§5: six of six sampled URLs already
1863		// 403) would otherwise report a fallback for every warmed neighbour on the
1864		// track, whether or not anyone swiped to it.
1865		//
1866		// INTERIM, not the destination. The durable fix is a seam change — a
1867		// warm-only adapter entry point, or an "emissions suppressed until activated"
1868		// clause on the instance contract — recorded as future work in
1869		// IMPLEMENTATION-PLAN item O. Until that lands, isolation is the consumer's
1870		// job and this is the one place it happens.
1871		//
1872		// Keyed on `slide.warm`, NOT on `index !== currentIndex`. The narrower key is
1873		// the deliberate choice: an ACTIVATED slide sitting off-screen (paused, per
1874		// §4.4) keeps its pre-existing escalation behaviour, which SMASH-1979 has no
1875		// mandate to change. Only media that exists speculatively is silenced.
1876		if (slide && slide.warm) return;
1877
1878		if (kind === 'error') {
1879			var from = tierName(slide.tier);
1880			var toTier = nextChainIndex(slide.tier + 1, slide.post);
1881			var to = toTier > slide.tier ? tierName(toTier) : null;
1882			emitAnalytics('swipeview_playback_fallback', {
1883				index: index, from: from, to: to, reason: (detail && detail.reason) || 'load-error'
1884			});
1885			instantiateTier(slide, index, slide.tier + 1);
1886			if (index === currentIndex) activateSlide(index, /* alreadyCurrent */ true);
1887			return;
1888		}
1889		if (kind === 'playing') {
1890			slide.playingSeen = true;
1891			clearWatchdog(slide);
1892			if (index === currentIndex) updateChrome();
1893			return;
1894		}
1895		if (kind === 'ended') {
1896			if (index === currentIndex && slide.inst && slide.inst.restart) slide.inst.restart();
1897			return;
1898		}
1899		if (kind === 'autoplay_blocked') {
1900			emitAnalytics('swipeview_autoplay_blocked', { index: index, intended: detail && detail.intended });
1901			// Task B (SMASH-1851, mirrors SMASH-1853): only arm the pill for
1902			// the slide the visitor is actually looking at — a neighbour
1903			// slot can independently hit this same rejection (e.g. while
1904			// pre-activating ahead of a swipe) and must not pop a pill for
1905			// an off-screen slide. Only an UNMUTED attempt being refused
1906			// arms it; a rejected MUTED retry means autoplay is blocked
1907			// outright, which the pill has nothing to offer for.
1908			if (index === currentIndex && detail && detail.intended === 'unmuted') {
1909				// Rule 1.7b: the design's "Tap to unmute" label is the REFUSAL
1910				// arm in our model, so this is the one event that can turn it
1911				// on. Sticky for the session rather than per-slide, because the
1912				// browser's autoplay policy is a property of the page, not of
1913				// the clip — once sound has been refused it will be refused on
1914				// the next slide too, which is exactly why the reference replays
1915				// its label per slide.
1916				autoplayRefused = true;
1917				updateChrome();
1918				startSoundHint();
1919			}
1920			return;
1921		}
1922	}
1923
1924	// Shared play-with-fallback for the native <video> tier (SMASH-1851
1925	// §4.2 revision — sound-on-open, mirrors SMASH-1853). "If unmuted
1926	// playback is refused for want of a qualifying gesture, the viewer MUST
1927	// fall back to muted and continue... MUST NOT stall on a rejected
1928	// play() promise." This is the ONE place that logic lives — play(),
1929	// restart() and setMuted(false) all route through it, so the fallback
1930	// can't stay fixed in one call site while silently regressing in
1931	// another. That regression shape is not hypothetical: a bare
1932	// `video.play().catch(() => {})` in restart() (the §4.3 loop-at-end
1933	// path) would swallow a rejected replay without ever muting, emitting,
1934	// or re-arming the pill, leaving the shell believing playback was fine
1935	// while the element had in fact stopped (a rejected play() leaves the
1936	// video paused).
1937	//
1938	// `muted` is a plain boolean INTENT, asserted on `video.muted` directly
1939	// — no double negation anywhere in this file. Every caller passes the
1940	// value it actually wants, never a negation of some other flag (that
1941	// exact bug — `play({muted: !userMuted})` — shipped in a sibling port
1942	// of this same viewer and is the reason this helper exists as one
1943	// place, not one per call site).
1944	// The ONE writer of `unmuteSucceeded` (2026-08-24 feedback). Kept as a named
1945	// function rather than an inline assignment so the test harnesses that
1946	// extract attemptPlay() have something to stub and spy on — an inline
1947	// `unmuteSucceeded = true` would, in the non-strict scope those harnesses
1948	// evaluate, silently create a global instead of failing loudly.
1949	function noteUnmuteSucceeded() {
1950		unmuteSucceeded = true;
1951	}
1952
1953	// ── iOS audio priming (SMASH-1851, investigation 2026-09-09 Part A) ───────
1954	// WebKit banks the audible-playback grant PER MEDIA ELEMENT, and it banks it
1955	// on a gesture-backed play(). A freshly created element has nothing banked,
1956	// however good the gesture that created it — which Chrome and Firefox hide
1957	// completely, because they gate autoplay per ORIGIN and a new element
1958	// inherits the page's permission. That asymmetry is why this shipped.
1959	//
1960	// So §4.1's unmuted attempt was being refused on iOS for every slide, and
1961	// the refusal arm (rule 1.7's "Tap to unmute" label) fired every time —
1962	// working as designed, on a viewer that should never have needed it.
1963	//
1964	// The fix is to bank a grant on the element BEFORE asking it for sound: a
1965	// muted play() while the gesture's activation is still live, immediately
1966	// paused. Muted playback needs no permission, so it succeeds and the element
1967	// keeps the grant; the unmuted play() that follows is then allowed.
1968	//
1969	// THREE THINGS HERE ARE LOAD-BEARING.
1970	//
1971	// 1. The pause is SYNCHRONOUS, not chained off the play promise. attemptPlay
1972	//    issues the real play() later in this same task, so a pause landing in a
1973	//    later microtask would stop the playback we just started — the primer
1974	//    would break the thing it exists to enable. Pausing immediately makes
1975	//    the play() an invocation-under-activation and nothing more, which is
1976	//    all the grant needs. It also makes play() reject with AbortError, which
1977	//    is expected and swallowed rather than routed into the §4.2 refusal path
1978	//    (that path is for a REAL refusal, and treating a self-inflicted abort
1979	//    as one would arm the label we are trying to make unnecessary).
1980	//
1981	// 2. It runs ONLY for an element created for ACTIVATION, never for a
1982	//    pre-warm. §4.4 requires a warmed element to be "created muted and not
1983	//    playing" with metadata as its buffering ceiling, and a play() — even a
1984	//    muted one — commits past that. Priming a warm element would also be
1985	//    pointless: ensureSlideMedia() releases and rebuilds it on activation
1986	//    (§4.4 rule 2, see its comment), so the element that actually plays is
1987	//    ALWAYS a fresh one created inside the activating gesture. Priming at
1988	//    creation therefore covers every path with no §4.4 cost at all.
1989	//
1990	// 3. It needs no once-per-element guard, because instantiateNativeVideo()
1991	//    creates exactly one element per call. A guard would be worse than
1992	//    redundant: the watchdog-escalation path builds an element with no
1993	//    gesture in scope, where priming legitimately fails, and a sticky flag
1994	//    would then block the next genuine attempt.
1995	//
1996	// WHAT THIS DOES NOT FIX, recorded so it is not mistaken for solved: the
1997	// 4000ms watchdog escalation (armWatchdog -> handleAdapterEvent('error') ->
1998	// instantiateTier) runs from a TIMER, so no activation is live and the new
1999	// element cannot bank anything. §4.1's muted fallback carries that case,
2000	// which is the failure arm behaving correctly.
2001	//
2002	// The per-element banking behaviour is an inference about WebKit internals —
2003	// consistent with the reported symptom, not provable from this repo. On-device
2004	// confirmation is Asmita's iPhone, and the investigation names the
2005	// discriminator: audio persisting on BACK-swipes but not forward ones proves
2006	// element identity is the cause, because ensureSlideMedia() reuses a retained
2007	// element going backward.
2008	function primeForAudio(v) {
2009		if (!v || !v.src) return;
2010		try {
2011			v.muted = true;
2012			var p = v.play();
2013			// Synchronous — see note 1 above.
2014			v.pause();
2015			// The abort this pause causes is ours, and must not reach §4.2.
2016			if (p && typeof p.catch === 'function') p.catch(function () {});
2017		} catch (e) { /* a browser that refuses outright leaves us no worse off */ }
2018	}
2019
2020	function attemptPlay(v, slide, index, muted) {
2021		v.muted = muted;
2022		var p = v.play();
2023		if (!p || typeof p.catch !== 'function') return;
2024		// A RESOLVED unmuted play() is the only honest proof the browser actually
2025		// granted audio — re-checking `v.muted` at resolve time rather than
2026		// trusting the intent we passed in, because the fallback below (or an
2027		// explicit mute racing us) may have flipped it in the meantime. The
2028		// rejection arm is a no-op here: the existing .catch() below owns that
2029		// path, and leaving this one empty keeps the promise from going unhandled.
2030		if (!muted && typeof p.then === 'function') {
2031			p.then(function () { if (!v.muted) noteUnmuteSucceeded(); }, function () {});
2032		}
2033		p.catch(function () {
2034			if (!muted) {
2035				v.muted = true;
2036				handleAdapterEvent(slide, index, 'autoplay_blocked', { intended: 'unmuted' });
2037				v.play().catch(function () {
2038					handleAdapterEvent(slide, index, 'autoplay_blocked', { intended: 'muted' });
2039				});
2040			} else {
2041				handleAdapterEvent(slide, index, 'autoplay_blocked', { intended: 'muted' });
2042			}
2043		});
2044	}
2045
2046	function instantiateNativeVideo(slide, index) {
2047		var v = document.createElement('video');
2048		var src = safeMediaSrc(slide.post.videoSrc);
2049		v.src = src;
2050		var poster = safeMediaSrc(slide.post.poster);
2051		if (poster) v.poster = poster;
2052		v.muted = true; // Pre-gesture safe default only — attemptPlay() sets the real intended value synchronously once play() is actually invoked (below), which happens before this element ever paints a frame.
2053		v.playsInline = true;
2054		v.setAttribute('playsinline', ''); // iOS Safari: required or playback goes fullscreen.
2055		v.loop = false; // Looping is driven explicitly on `ended` so the loop event always fires (§4.3).
2056		v.preload = 'metadata'; // Full download only once actually activated (§4.4 spirit, even without full windowing).
2057		v.className = 'sbi-qs-video';
2058
2059		// Bank the audible-playback grant while the activating gesture is still
2060		// live (see primeForAudio). `slide.warm` is set by prewarmSlide() BEFORE
2061		// it calls instantiateTier(), so this reads false only for an element
2062		// being built for activation — which is the one that will be asked for
2063		// sound, and the only one §4.4 permits us to play.
2064		if (!slide.warm) primeForAudio(v);
2065
2066		v.addEventListener('error', function () { handleAdapterEvent(slide, index, 'error', { reason: 'media-load-failed' }); });
2067		// The earliest point videoWidth/videoHeight are readable. Without this the
2068		// first correct band would wait for the next 250ms tick, and the card would
2069		// visibly jump mid-entrance on a slow-loading source.
2070		// The earliest point videoWidth/videoHeight are readable, and therefore
2071		// the earliest point rule 1.14's contain-vs-cover question has an answer.
2072		// Before this the honest answer is `cover`, which is also the common case
2073		// — so a late decision never shows a black band it then removes.
2074		v.addEventListener('loadedmetadata', function () { applyFit(slide); });
2075		// §6.1's loading affordance (rule 1.19). `waiting` and `stalled` are the
2076		// two signals a <video> gives for "I have nothing to paint"; `playing`
2077		// and `canplay` are the two for "I do". Driven off the element's own
2078		// events rather than a timer, so the spinner cannot linger after the
2079		// media recovers.
2080		v.addEventListener('waiting', function () { setBuffering(slide, true); });
2081		v.addEventListener('stalled', function () { setBuffering(slide, true); });
2082		v.addEventListener('canplay', function () { setBuffering(slide, false); });
2083		v.addEventListener('playing', function () { setBuffering(slide, false); handleAdapterEvent(slide, index, 'playing'); });
2084		v.addEventListener('ended', function () { handleAdapterEvent(slide, index, 'ended'); });
2085		// §4.2 revision (SMASH-1851, mirrors SMASH-1853): `volumechange`
2086		// fires for BOTH a user-driven change and a direct property
2087		// assignment (attemptPlay()'s fallback sets `v.muted` this way), so
2088		// this is the one listener that keeps the mute button/pill in sync
2089		// with reality regardless of which code path changed it — never
2090		// the `userMuted` intent, which can legitimately diverge from the
2091		// real property for as long as a rejected retry's fallback is in
2092		// effect.
2093		v.addEventListener('volumechange', function () {
2094			if (index === currentIndex) updateMuteChrome();
2095		});
2096
2097		slide.stage.appendChild(v);
2098
2099		return {
2100			name: 'native-video',
2101			el: v,
2102			play: function (opts) {
2103				var wantMuted = !opts || opts.muted !== false;
2104				v.preload = 'auto';
2105				attemptPlay(v, slide, index, wantMuted);
2106			},
2107			pause: function () { v.pause(); },
2108			setMuted: function (m) {
2109				// Muting never needs a play() call; unmuting does, and
2110				// routes through the SAME fallback as every other
2111				// activation so a rejected retry still degrades to muted +
2112				// re-arms the pill instead of leaving the element silently
2113				// paused.
2114				if (m) {
2115					v.muted = true;
2116				} else {
2117					attemptPlay(v, slide, index, false);
2118				}
2119			},
2120			// MUST NOT report optimistically (§9.1) — real paused/ended state only.
2121			isPlaying: function () { return !v.paused && !v.ended; },
2122			// Rex-class review demand (mirrors SMASH-1853 findings #1/#2):
2123			// the shell must render the REAL mute state, never the
2124			// `userMuted` intent — a rejected attemptPlay() retry can leave
2125			// the element muted even though intent never flipped (§4.2's
2126			// fallback is per-attempt, not persisted). Sampled by the
2127			// volumechange listener above and by the 250ms tick() poll.
2128			isMuted: function () { return v.muted; },
2129			progress: function () { return { current: v.currentTime || 0, duration: v.duration || 0 }; },
2130			restart: function () {
2131				// §4.3 loop-at-end — the spec calls this out by name as an
2132				// activation path that can land outside a user gesture.
2133				// Route through the SAME attemptPlay() fallback as
2134				// play()/setMuted() rather than a bare
2135				// `video.play().catch(() => {})`: that shape swallowed a
2136				// rejected replay silently (no mute, no emit, no pill) in
2137				// a sibling port of this viewer. Preserve whatever mute
2138				// state is currently in effect rather than forcing either
2139				// value.
2140				try { v.currentTime = 0; } catch (e) {}
2141				attemptPlay(v, slide, index, v.muted);
2142			},
2143			destroy: function () { try { v.pause(); v.removeAttribute('src'); v.load(); } catch (e) {} v.remove(); }
2144		};
2145	}
2146
2147	function instantiateEmbed(slide, index) {
2148		var embedSrc = embedUrlForPermalink(slide.post.permalink);
2149		var frame = document.createElement('iframe');
2150		frame.src = embedSrc;
2151		frame.allow = 'autoplay; encrypted-media; picture-in-picture';
2152		frame.referrerPolicy = 'strict-origin-when-cross-origin';
2153		frame.setAttribute('scrolling', 'no');
2154		frame.setAttribute('title', 'Instagram embed');
2155		frame.className = 'sbi-qs-embed';
2156		var loaded = false;
2157
2158		// §5.2 / §9.1: a cross-origin embed's `load` fires even for an error
2159		// document — this is the accepted, documented limitation. Reporting
2160		// `playing` on load is optimistic BY NECESSITY (there is no other
2161		// signal), and is what lets a genuinely-working embed disarm its own
2162		// watchdog instead of being escalated away every time.
2163		frame.addEventListener('load', function () {
2164			loaded = true;
2165			handleAdapterEvent(slide, index, 'playing');
2166		});
2167
2168		slide.stage.appendChild(frame);
2169
2170		return {
2171			name: 'ig-reel-embed',
2172			el: frame,
2173			play: function () {},
2174			pause: function () {},
2175			setMuted: function () {},
2176			isPlaying: function () { return loaded; },
2177			// No real mute control on this tier (caps.mute is false, so the
2178			// shell's toggle stays disabled and the pill's caps.mute gate
2179			// keeps it hidden regardless) — advisory only.
2180			isMuted: function () { return false; },
2181			progress: function () { return null; },
2182			restart: function () {},
2183			destroy: function () { frame.src = 'about:blank'; frame.remove(); }
2184		};
2185	}
2186
2187	function instantiatePosterLink(slide, index) {
2188		var poster = safeMediaSrc(slide.post.poster);
2189		if (poster) {
2190			var img = document.createElement('img');
2191			img.className = 'sbi-qs-poster';
2192			img.src = poster;
2193			img.alt = '';
2194			// Rule 1.14 applies to the poster tier identically — a landscape
2195			// poster letterboxes over its own blurred blow-up rather than being
2196			// cropped. `load` is the poster's equivalent of `loadedmetadata`.
2197			img.addEventListener('load', function () { applyFit(slide); });
2198			slide.stage.appendChild(img);
2199		}
2200		// Terminal tier: nothing plays, but it's a valid presentation. Report
2201		// `playing` immediately so the watchdog stays quiet — there's nowhere
2202		// left to escalate to. Reliability is measured via swipeview_terminal_tier
2203		// instead (already emitted by instantiateTier()).
2204		handleAdapterEvent(slide, index, 'playing');
2205		return {
2206			name: 'poster-link',
2207			play: function () {},
2208			pause: function () {},
2209			setMuted: function () {},
2210			isPlaying: function () { return true; },
2211			// No audio at all on this tier (caps.mute is false) — `true` is
2212			// the more honest advisory default (nothing is playing). Also
2213			// the GDPR-no-consent shape's presentation, where the sound
2214			// control MUST NOT appear at all — the caps.mute gate in
2215			// updateMuteChrome() is what actually enforces that.
2216			isMuted: function () { return true; },
2217			progress: function () { return null; },
2218			restart: function () {},
2219			destroy: function () {}
2220		};
2221	}
2222
2223	function ensureSlideMedia(index) {
2224		var slide = slides[index];
2225		if (!slide) return;
2226		// §4.4 rule 2 — "A stale pre-warm MUST NOT skip tiers." A pre-warmed slide
2227		// arrives here already carrying an instantiated tier, and the ONE thing
2228		// activation may not do is inherit it: "On activation the chain MUST be
2229		// evaluated from tier 1 per §5.1 against the freshly-read source, exactly as
2230		// if no pre-warm had happened."
2231		//
2232		// So the warm element is released and the chain re-runs from tier 1 (index 0
2233		// here) below, through the identical `instantiateTier(slide, index, 0)` call a
2234		// never-warmed slide takes. Without this release the `slide.tier !== -1` bail
2235		// on the next line would hand activation the warm element, and a pre-warm that
2236		// succeeded minutes ago would silently decide the outcome of an activation
2237		// happening now — against a signed URL whose ~48h window may well have closed
2238		// in between (§5). That is precisely the authority §4.4 denies it: "pre-warm
2239		// may only ever make activation FASTER. It has no authority over what
2240		// activation DECIDES."
2241		//
2242		// The speed benefit survives intact, because what a pre-warm actually warms is
2243		// the HTTP cache, not the element instance — a fresh element on a still-valid
2244		// URL starts from cached bytes. What does not survive is the stale element's
2245		// verdict, which is the whole point.
2246		if (slide.warm) releaseWarmSlide(slide);
2247		if (slide.tier !== -1) return;
2248		instantiateTier(slide, index, 0);
2249	}
2250
2251	// ── Adjacent pre-warm (§4.4, SMASH-1979) ────────────────────────────────
2252	//
2253	// §4.4's slot rules bound the COST of preloading and leave the BENEFIT
2254	// unspecified, so before this every next-swipe paid a cold start and the visitor
2255	// watched a loader on a slide the viewer could already have been warming. This
2256	// block is the benefit half, and every one of its bounds is a spec MUST.
2257	//
2258	// Worth stating plainly what this viewer is, because it changes what the rules
2259	// require: Instagram renders EVERY slide and instantiates media lazily on
2260	// activation (§4.4's "Where the implementations stand today" names it). Rendering
2261	// a slide implies nothing about its media — so ±1 is a bound this code has to
2262	// impose deliberately rather than inherit from a three-slot DOM window, and the
2263	// release half (releaseWarmOutsideWindow) has to be explicit for the same reason:
2264	// "where every slide is rendered, it MUST be done explicitly, because rendering a
2265	// slide implies nothing about releasing its media."
2266
2267	// §4.4: "the viewer SHOULD pre-warm the NEXT slide's media resource, then the
2268	// PREVIOUS one. It MUST NOT pre-warm any slide beyond ±1." Both facts live in
2269	// this one array — the offsets AND their order — and it is the single source for
2270	// the warm set and the release window alike (isWithinPrewarmWindow reads it too).
2271	// That coupling is intentional: widening the warm set without widening the
2272	// release window, or the reverse, is the drift that would reintroduce the
2273	// unbounded accumulation the ±1 bound exists to prevent, and it cannot be
2274	// expressed here without editing one array that both halves read.
2275	//
2276	// Not a tunable. The bound is the point, not an implementation convenience: the
2277	// track is a snapshot of a whole feed (§2.3) the visitor may never scroll, so
2278	// every slide warmed past the adjacent pair is a fetch nobody is likely to
2279	// redeem. ±1 holds "regardless of what the host renders".
2280	var PREWARM_OFFSETS = [1, -1];
2281
2282	// §4.4: "Transport, not tier index — never pre-warm an iframe." The spec is
2283	// emphatic that the scoping is by TRANSPORT and not by position in the chain,
2284	// because "scoping this rule by index rather than by transport would permit
2285	// precisely the case its rationale forbids" — a renumbered or inserted tier would
2286	// silently move an iframe into pre-warm range. So each tier declares what it IS
2287	// and pre-warm asks that question, rather than comparing an integer.
2288	//
2289	// Three properties of the iframe transport compound, and none of them are
2290	// properties of the tier index: an iframe is heavy; it exposes no playback API to
2291	// warm against (the frame's `load` event is all the page can observe — §5.2's
2292	// accepted limitation); and it is a THIRD-PARTY load carrying its own consent
2293	// semantics, so a frame fetched for a slide the visitor never reaches is "a
2294	// third-party request made on their behalf for content they never asked to see, a
2295	// privacy cost under §5.3 and not one the viewer may incur speculatively".
2296	// The terminal tier is a poster image plus the permalink CTA — a link-out, with
2297	// no media element to warm at all.
2298	var TIER_TRANSPORT = {
2299		0: 'media-element', // native <video> — an HTMLMediaElement, the only warmable transport
2300		1: 'iframe',        // IG Reel embed — MUST NOT be pre-warmed, whatever its index
2301		2: 'link'           // terminal poster + "View on Instagram" CTA — nothing to warm
2302	};
2303
2304	function tierIsPrewarmable(tierIndex) {
2305		return TIER_TRANSPORT[tierIndex] === 'media-element';
2306	}
2307
2308	// §4.4 "Data preferences" + the Level B gate, resolved in ONE place so the whole
2309	// policy is decided once per activation rather than re-derived per neighbour.
2310	// Returns exactly one of:
2311	//
2312	//   'none'     — pre-warm suppressed entirely, Level A included. No element.
2313	//   'metadata' — Level A, the normative floor and the default.
2314	//   'buffer'   — Level B, a MAY, only on a plausibly-fast connection.
2315	//
2316	// Read fresh on every activation, never cached: `navigator.connection` is live, so
2317	// a visitor who moves onto a metered connection mid-session stops being warmed
2318	// from the next swipe onward, and one who enables Save-Data stops immediately.
2319	function prewarmLevel() {
2320		var conn = navigator.connection;
2321		// §4.4: "Pre-warm MUST be suppressed entirely — no element instantiated,
2322		// Level A included — when navigator.connection.saveData is true." Save-Data is
2323		// a hard suppression rather than one input among several because it is the only
2324		// signal of metered INTENT the platform offers — effectiveType separates fast
2325		// from slow, not metered from unmetered (a fast cellular connection reads
2326		// '4g').
2327		if (conn && conn.saveData === true) return 'none';
2328		// §4.4: "Plausibly fast means navigator.connection.saveData is false AND
2329		// effectiveType is 4g."
2330		//
2331		// Both halves are compared with === against the exact values the spec names,
2332		// which is load-bearing and not stylistic. A truthiness test — `!conn.saveData`
2333		// — would read an ABSENT saveData (undefined) as "false" and promote an API
2334		// that never reported the signal all the way to Level B. §4.4 forbids exactly
2335		// that inference: "Absence of the signal is not permission — where the Network
2336		// Information API is unavailable, an implementation MUST stay at Level A",
2337		// which is "the same fail-safe direction §2.4 takes on suppression: the unknown
2338		// case resolves to the cheaper behaviour, never the more expensive one."
2339		if (conn && conn.saveData === false && conn.effectiveType === '4g') return 'buffer';
2340		return 'metadata';
2341	}
2342
2343	// The ±1 adjacency, derived from PREWARM_OFFSETS rather than restated as a
2344	// radius, so the release window can never disagree with the warm set. The active
2345	// slide is inside its own window by definition — it is not warm, but including it
2346	// keeps this a straightforward "is this index in the adjacency set" question.
2347	function isWithinPrewarmWindow(index, activeIndex) {
2348		if (index === activeIndex) return true;
2349		for (var k = 0; k < PREWARM_OFFSETS.length; k++) {
2350			if (index === activeIndex + PREWARM_OFFSETS[k]) return true;
2351		}
2352		return false;
2353	}
2354
2355	// §4.4: "A warmed slide that leaves the ±1 adjacency MUST be released — its media
2356	// element torn down or its source detached — and warmed elements MUST NOT
2357	// accumulate beyond the adjacency set." (Tariq's blocking condition B2 on the
2358	// spec.) The adapter's own destroy() does the teardown properly — pause,
2359	// removeAttribute('src'), load(), remove() — which is what actually releases the
2360	// buffers rather than merely orphaning a node that keeps downloading.
2361	//
2362	// Resetting `tier` to -1 is as important as the teardown: it returns the slide to
2363	// "not instantiated", which is what lets ensureSlideMedia() re-run the chain from
2364	// tier 1 if the visitor ever does arrive here (§4.4 rule 2).
2365	//
2366	// `terminalReported` is deliberately NOT reset — it is a once-per-session
2367	// analytics latch, and a release is not a new session.
2368	function releaseWarmSlide(slide) {
2369		if (!slide || !slide.warm) return;
2370		clearWatchdog(slide);
2371		if (slide.inst && slide.inst.destroy) slide.inst.destroy();
2372		slide.inst = null;
2373		slide.caps = NO_CAPS;
2374		slide.tier = -1;
2375		slide.playingSeen = false;
2376		slide.warm = false;
2377		if (slide.stage) slide.stage.innerHTML = '';
2378	}
2379
2380	// Sweeps warm elements that have fallen outside the adjacency set. The
2381	// `slide.warm` gate is what keeps this surgical: an ACTIVATED slide that is now
2382	// off-screen is never released here, because §4.4 requires off-screen slides be
2383	// "paused, not destroyed, so a one-step back-swipe is instant". Only speculative
2384	// media is reclaimed — which is also exactly the accumulation SMASH-1979
2385	// introduces and therefore owes a bound.
2386	function releaseWarmOutsideWindow(activeIndex) {
2387		slides.forEach(function (slide, i) {
2388			if (!slide.warm) return;
2389			if (isWithinPrewarmWindow(i, activeIndex)) return;
2390			releaseWarmSlide(slide);
2391		});
2392	}
2393
2394	function prewarmSlide(index, level) {
2395		var slide = slides[index];
2396		// Off either end of the track. §2.3 clamps navigation the same way, and
2397		// §4.4 is explicit that "declining to pre-warm is not a failure" — nothing is
2398		// emitted and nothing is logged.
2399		if (!slide) return;
2400		// Already instantiated, by a previous warm or by an earlier activation.
2401		// Both must be left alone, for different reasons: re-warming is pointless,
2402		// and rebuilding an activated slide's element would restart the playback
2403		// §4.4 preserves by pausing rather than destroying.
2404		if (slide.tier !== -1) return;
2405		// Ask the chain which tier this post would actually get, then warm ONLY if
2406		// that tier's transport is an HTMLMediaElement. Routing through
2407		// nextChainIndex() rather than assuming tier 0 is what makes §5.4 fall out for
2408		// free: a source tier 1 would decline (no usable videoSrc) resolves to the
2409		// embed or terminal tier, neither of which is warmable, so "a source tier 1
2410		// would decline MUST NOT be pre-warmed" needs no separate check. A
2411		// GDPR-suppressed embed tier is handled by the same call.
2412		var tier = nextChainIndex(0, slide.post);
2413		if (!tierIsPrewarmable(tier)) return;
2414
2415		// ORDERING IS LOAD-BEARING: the flag goes up BEFORE instantiation, never
2416		// after. instantiateTier() can emit synchronously — instantiatePosterLink()
2417		// reports `playing` inline, and the terminal branch emits
2418		// swipeview_terminal_tier — so a flag set afterwards would leave a window in
2419		// which this slide's emissions reach handleAdapterEvent() unguarded. Unreachable
2420		// today (the transport check above means only tier 0 gets here, and the native
2421		// adapter emits nothing during construction), which is exactly why it is
2422		// written down: the guard costs one line now and survives a tier being added.
2423		slide.warm = true;
2424		instantiateTier(slide, index, 0);
2425
2426		// Level A needs nothing further. instantiateNativeVideo() already builds the
2427		// element the floor requires — `preload = 'metadata'`, `muted = true`, no
2428		// play() call anywhere in construction — and pre-warm never invokes
2429		// inst.play(), so the element is paused, muted and metadata-capped by
2430		// construction. That "created muted and not playing, WHATEVER the session's
2431		// mute state is" is §4.4's own emphasis, and the reason it is emphasised is the
2432		// failure it prevents: §4.2's unmute persistence "attaches to a 
2432slide when it
2433		// BECOMES active, not when it is warmed — applying the session's unmuted state
2434		// at creation time is how an off-screen slide ends up audible". Note that
2435		// nothing here reads `userMuted`; that is the mechanism, not an oversight.
2436		if (level !== 'buffer') return;
2437
2438		// Level B (§4.4, a MAY): buffer ahead of metadata on a plausibly-fast
2439		// connection. Written directly on the element because the adapter couples its
2440		// own `preload = 'auto'` promotion to play() — and play() is the one thing a
2441		// pre-warm must not do. This is the only place outside the adapter that writes
2442		// `preload`, and it is reached only for a tier whose transport is an
2443		// HTMLMediaElement, so `inst.el` is the <video>.
2444		if (slide.inst && slide.inst.el) slide.inst.el.preload = 'auto';
2445	}
2446
2447	// THE single pre-warm entry point. Called from activateSlide() and nowhere else,
2448	// so "on activation or navigation" is satisfied for every input source at once —
2449	// wheel, touch, keyboard, open(), and the post-escalation re-activation — without
2450	// a second trigger that could drift from this one.
2451	//
2452	// On the trigger moment: §4.4 phrases the scope as "WHILE the active slide is
2453	// playing", and this fires at activation rather than on the active slide's
2454	// `playing` event. Deliberate, and the cheaper direction of the two. A visitor can
2455	// swipe before the active slide ever reports `playing` — a stalled slide is
2456	// precisely when a warmed neighbour helps most, and gating on `playing` would
2457	// withhold the warm exactly then, up to the 4000ms the §5.2 watchdog allows.
2458	// Level A is metadata-only by construction, which is what makes the earlier
2459	// trigger affordable: it is not the "three concurrent full downloads" §4.4
2460	// refuses as a default.
2461	function prewarmAdjacent(activeIndex) {
2462		// Release BEFORE warming. The two halves are one lifecycle — §4.4: "The bound
2463		// is a lifecycle, not just an action" — and releasing first means the count of
2464		// live warm elements never even momentarily exceeds the adjacency set.
2465		releaseWarmOutsideWindow(activeIndex);
2466
2467		var level = prewarmLevel();
2468		// §4.4 Save-Data: "suppressed entirely — no element instantiated, Level A
2469		// included". Placed after the release so a visitor who turns Save-Data on
2470		// mid-session still gets existing warm elements reclaimed rather than frozen
2471		// in place.
2472		if (level === 'none') return;
2473
2474		for (var k = 0; k < PREWARM_OFFSETS.length; k++) {
2475			prewarmSlide(activeIndex + PREWARM_OFFSETS[k], level);
2476		}
2477	}
2478
2479	// ── Navigation ──────────────────────────────────────────────────────────
2480
2481	// The JS half of the vh-then-dvh convention documented at the top of
2482	// css/sbi-quickscroll.css (team feedback 2026-08-24). The track moves one
2483	// viewport height per slide, so this unit MUST match .sbi-qs-post's height
2484	// unit. If the CSS moves to dvh and this stays on vh, every slide lands
2485	// N * (100vh - 100dvh) out of position — invisible on desktop (where the two
2486	// units are equal) and progressively worse on a phone with every swipe.
2487	//
2488	// Two assignments to the same property, vh FIRST: the CSSOM ignores a value
2489	// it cannot parse, so a browser without dvh support silently keeps the vh
2490	// offset from the line above. This is the exact equivalent of the
2491	// stylesheet's duplicate-declaration fallback — that cascade trick isn't
2492	// available here because jQuery's .css({...}) takes one value per key.
2493	// SMASH-1978 extends this with an optional px delta for the thumb-follow
2494	// drag. The drag is a PIXEL quantity (the finger's travel) composed over a
2495	// VIEWPORT-UNIT base (the slide's rest position), so the two cannot be added
2496	// in JS — they are added by the browser inside calc(). The fallback-pair
2497	// discipline above applies to the composed value exactly as it does to the
2498	// bare one: the vh form is written first, the dvh form second, and a browser
2499	// that cannot parse dvh silently keeps the vh composition.
2500	//
2501	// The sign is normalised into the operator (`- 40px`, never `+ -40px`).
2502	// calc() does accept a signed operand, but the negative-plus form is the
2503	// shape engines have historically been picky about, and it costs nothing to
2504	// emit the unambiguous one.
2505	function setTrackOffset(vhOffset, pxDelta) {
2506		var node = $track && $track[0];
2507		if (!node) return;
2508		var d = pxDelta || 0;
2509		// Builds one unit's value. Kept as a local so the composition rule lives
2510		// in one place, while the two ASSIGNMENTS below stay written out — the
2511		// vh-then-dvh pair is the mechanism this function exists for, and hiding
2512		// it inside a loop or a second call would make the ordering invisible at
2513		// exactly the site whose comment is about that ordering.
2514		var offset = function (unit) {
2515			var base = vhOffset + unit;
2516			// No delta: emit the plain unit form the rest position has always
2517			// used, so a track at rest carries byte-identical CSS to what shipped
2518			// before the drag existed.
2519			if (d === 0) return base;
2520			return 'calc(' + base + (d < 0 ? ' - ' + Math.abs(d) : ' + ' + d) + 'px)';
2521		};
2522		node.style.transform = 'translate3d(0, ' + offset('vh') + ', 0)';
2523		node.style.transform = 'translate3d(0, ' + offset('dvh') + ', 0)';
2524	}
2525
2526	function goToIndex(newIndex, opts) {
2527		opts = opts || {};
2528		if (transitioning) return;
2529		// §2.3: clamped at both ends — out-of-range input is a strict NO-OP,
2530		// matching the TikTok viewer. The earlier "gentle bounce" (a spec MAY,
2531		// not a requirement) was removed on maintainer feedback 2026-08-19:
2532		// its transform re-fired on every key-repeat event — navigate() never
2533		// arms navLocked for out-of-range targets and bounce never set
2534		// `transitioning` — so holding ArrowUp on the first reel (or ArrowDown
2535		// on the last) visibly juddered the track ~30x/sec instead of playing
2536		// one clean overshoot.
2537		if (newIndex < 0 || newIndex >= posts.length) return;
2538		currentIndex = newIndex;
2539		if (slidesViewed) slidesViewed.add(currentIndex);
2540		transitioning = true;
2541
2542		var instant = opts.smooth === false || prefersReducedMotion();
2543		var translateY = -newIndex * 100;
2544		// §3.2: reduced motion REMOVES the transition entirely — it does not
2545		// merely shorten it. `transition: none` (not a smaller duration) is
2546		// the only thing that satisfies "the slide changes instantly". Set
2547		// BEFORE the transform so an instant move never animates the first frame.
2548		$track.css('transition', instant ? 'none' : 'transform ' + TRANSITION_MS + 'ms cubic-bezier(0.22, 0.61, 0.36, 1)');
2549		setTrackOffset(translateY);
2550		// ── The commit: the rail lands at rest, INSTANTLY, and stays there ───
2551		// This is the line Asmita's report is about. The track animates the
2552		// remaining distance; the rail does not move at all, and updateChrome()
2553		// on the next line swaps its counts in the same paint. Unconditionally
2554		// instant — a commit is never the animated case, so this is not gated
2555		// on reduced motion (setRailTravel is instant for `animate: false`
2556		// either way).
2557		//
2558		// It runs on EVERY navigation path by construction: wheel, key, chevron
2559		// and drag-commit all route through goToIndex(). A drag-commit is the
2560		// one that needs it — `dragDeltaPx` is non-zero when the gesture ends,
2561		// so without this the rail would stay parked mid-drag while the track
2562		// snapped to the new slide.
2563		setRailTravel(0, false);
2564
2565		updateChrome();
2566		activateSlide(newIndex, false);
2567
2568		var settle = instant ? 0 : TRANSITION_MS;
2569		setTimeout(function () { transitioning = false; }, settle);
2570	}
2571
2572	// §3.1: a single 420ms cooldown gates every input source (wheel/touch/key),
2573	// separate from the 320ms transition, so a fast repeat can't outrun it.
2574	function navigate(delta) {
2575		if (navLocked) return;
2576		var target = currentIndex + delta;
2577		if (target < 0 || target >= posts.length) return; // §2.3 clamp — strict no-op, see goToIndex.
2578		navLocked = true;
2579		setTimeout(function () { navLocked = false; }, NAV_COOLDOWN_MS);
2580		// Same reasoning for the pause glyph: a glyph still dissolving over the
2581		// slide being swiped away describes a playback state on a video the
2582		// visitor is no longer looking at.
2583		clearFlash();
2584		goToIndex(target);
2585		emitAnalytics(delta > 0 ? 'swipeview_next' : 'swipeview_prev', { index: currentIndex });
2586	}
2587
2588	// Reads the ACTIVE slide's real mute state off its adapter instance, not
2589	// the `userMuted` intent — see instantiateNativeVideo()'s isMuted() for
2590	// why those two can diverge. `true` (muted) is the honest advisory
2591	// default for a slide with no instance yet, or a tier with no real mute
2592	// control at all (embed / poster-link — see their own isMuted()).
2593	function realMuted() {
2594		var slide = slides[currentIndex];
2595		var inst = slide && slide.inst;
2596		return inst && inst.isMuted ? inst.isMuted() : true;
2597	}
2598
2599	// §6.1/§4.2 (SMASH-1851, mirrors SMASH-1853): the mute button and the
2600	// "Tap to unmute" pill MUST reflect the media element's ACTUAL muted
2601	// property, never the `userMuted` intent — Rex-class review demand: a
2602	// rejected attemptPlay() retry can leave the element muted even though
2603	// intent never flipped (that per-attempt fallback is deliberate, see
2604	// attemptPlay()), so rendering off intent alone would let the button
2605	// claim sound is on when it is not.
2606	//
2607	// Called from three places, all needed: the video's own `volumechange`
2608	// listener (immediate sync the instant the real property changes,
2609	// however it changed), updateChrome() (every slide/nav/toggle
2610	// transition), and tick()'s 250ms poll (a defensive backstop matching
2611	// §6.1's refresh cadence for the rest of the chrome, in case a future
2612	// browser path changes `.muted` without firing `volumechange`).
2613	function updateMuteChrome() {
2614		var slide = slides[currentIndex];
2615		var caps = (slide && slide.caps) || NO_CAPS;
2616		var muted = realMuted();
2617
2618		// Rule 1.20 / §6.2: DISABLED, never omitted. The reference omits the
2619		// button entirely when the source has no audio, which leaves `m` flipping
2620		// a state with no control and no audible effect.
2621		$muteBtn.prop('disabled', !caps.mute);
2622		// The glyph reports the CURRENT state (crossed speaker == audio is off)
2623		// while aria-label names the ACTION ("Unmute") — the platform
2624		// convention, and the reference's own fixed "Toggle sound" reflects
2625		// neither (spec §4.4 item 2).
2626		setControlIcon($muteBtn, muted ? 'volumeOff' : 'volumeOn', 20);
2627		$muteBtn.attr('aria-label', muted ? 'Unmute' : 'Mute');
2628		// Rule 1.6's inverted paint: MUTED is loud (solid white), UNMUTED is
2629		// quiet (dark translucent). Driven by an attribute the stylesheet reads,
2630		// off the REAL sampled state, so rule 1.7a holds — the button paints
2631		// white whenever the viewer is muted, including when it is muted for a
2632		// reason the visitor never chose.
2633		$muteBtn.attr('data-state', muted ? 'off' : 'on');
2634		$container.attr('data-muted', muted ? 'true' : 'false');
2635
2636		// Rule 1.7's label gate, and the three-way distinction it exists for.
2637		// The label may only be out while ALL of these hold:
2638		//   - the tier HAS a mute control at all (caps.mute) — an embed or
2639		//     poster tier has no sound to unmute, so an affordance there is a
2640		//     promise the tier cannot keep (§6.2);
2641		//   - the viewer is really muted right now;
2642		//   - sound was REFUSED rather than chosen (autoplayRefused, and NOT
2643		//     userMuted) — nagging a visitor who deliberately muted is rule
2644		//     1.7c's explicit "respect the choice";
2645		//   - the visitor has never successfully unmuted this session
2646		//     (!unmuteSucceeded) — rule 1.7b's "stops forever once the visitor
2647		//     unmutes".
2648		// Any of them failing retracts the label immediately, which is why this
2649		// is one expression at the render gate rather than four scattered
2650		// conditions.
2651		if (!(caps.mute && muted && autoplayRefused && !userMuted && !unmuteSucceeded)) {
2652			stopSoundHint();
2653		}
2654	}
2655
2656	// ── The label's own timing (rule 1.6, measured) ──────────────────────────
2657	// Show at +350ms after activation, retract at 4500ms, and replay on each new
2658	// slide while still in the refused-muted state. Called from activateSlide()
2659	// so "each new slide" is by construction rather than by a second scheduler.
2660	function startSoundHint() {
2661		stopSoundHint();
2662		var slide = slides[currentIndex];
2663		var caps = (slide && slide.caps) || NO_CAPS;
2664		if (!caps.mute || !autoplayRefused || userMuted || unmuteSucceeded || !realMuted()) return;
2665		soundHintInTimer = setTimeout(function () {
2666			soundHintInTimer = null;
2667			// Re-checked at fire time, not only at schedule time: 350ms is long
2668			// enough for a visitor to have hit the mute button, and a label that
2669			// slides out after the state it describes has gone is worse than no
2670			// label.
2671			if (!realMuted() || userMuted || unmuteSucceeded) return;
2672			$muteBtn.addClass('sbi-qs-mute-hinting');
2673			soundHintOutTimer = setTimeout(function () {
2674				soundHintOutTimer = null;
2675				$muteBtn.removeClass('sbi-qs-mute-hinting');
2676			}, MUTE_HINT_OUT_MS - MUTE_HINT_IN_MS);
2677		}, MUTE_HINT_IN_MS);
2678	}
2679
2680	function stopSoundHint() {
2681		if (soundHintInTimer) { clearTimeout(soundHintInTimer); soundHintInTimer = null; }
2682		if (soundHintOutTimer) { clearTimeout(soundHintOutTimer); soundHintOutTimer = null; }
2683		if ($muteBtn && $muteBtn.length) $muteBtn.removeClass('sbi-qs-mute-hinting');
2684	}
2685
2686	function updateChrome() {
2687		// Rule 1.8: the VISIBLE counter is gone (the design has none, and the
2688		// frame's top-left is deliberately blank). §7.1's position announcement
2689		// survives AT-only.
2690		announceLive((currentIndex + 1) + ' of ' + posts.length);
2691		// ── §7.1 / rule 1.18: non-active slides are INERT ────────────────────
2692		// `inert` is the right primitive here and `aria-hidden` is not, which is
2693		// the opposite of the call made for the gesture layer (see its comment):
2694		// that element must keep pointer interaction, because pointer
2695		// interaction is its entire purpose. A non-active slide must lose
2696		// everything — it should not be reachable by Tab, should not be read by
2697		// AT, and should not be hit-testable. That is exactly `inert`'s
2698		// definition, and doing it with `aria-hidden` instead would leave
2699		// focusable links inside a hidden subtree, which is the "Blocked
2700		// aria-hidden" defect the gesture layer already taught this file.
2701		//
2702		// Measured against the reference: 0 of 12 slides inert, 157 focusables
2703		// in the viewer, and Tab from an open viewer walks 10 grid cards behind
2704		// the overlay before reaching the first viewer control (spec §4.1 item
2705		// 2). The design is silent on this because focus management is
2706		// invisible, so there is nothing to match — this is ours.
2707		//
2708		// Set as a PROPERTY, not an attribute, so a browser without `inert`
2709		// support (Safari < 15.5) simply ignores it rather than inheriting a
2710		// stray attribute; focusables() already scopes per-slide controls to the
2711		// active slide, so the fallback behaviour is the status quo rather than a
2712		// regression.
2713		$container.find('.sbi-qs-post').each(function () {
2714			var active = this.getAttribute('data-index') === String(currentIndex);
2715			this.classList.toggle('sbi-qs-post-active', active);
2716			this.inert = !active;
2717		});
2718
2719		var slide = slides[currentIndex];
2720		var post = slide && slide.post;
2721		var caps = (slide && slide.caps) || NO_CAPS;
2722		$progressBar.css('display', caps.progress ? '' : 'none');
2723
2724		// ── The session-level CTA's per-slide href (rule IG-6, §6f) ──────────
2725		// One element, re-pointed on slide change. §6f requires the CTA to be
2726		// permanent chrome that is never re-rendered or relocated as the tier
2727		// chain escalates — writing an href satisfies that far more strongly
2728		// than rebuilding a per-tier link would, because there is only ever one
2729		// of these and no tier code touches it.
2730		var href = post && isHttpUrl(post.permalink) ? post.permalink : '';
2731		if (href) {
2732			$viewPill.attr('href', href).removeAttr('aria-disabled');
2733		} else {
2734			// A post with no safe permalink has nowhere to send anyone. Left in
2735			// place and marked disabled rather than hidden, so the top row's
2736			// geometry does not change between slides while swiping.
2737			$viewPill.attr('href', '#').attr('aria-disabled', 'true');
2738		}
2739
2740		// ── The session-level rail's per-slide content (rule IG-3) ───────────
2741		// Same shape and the same moment as the CTA pill above: one element,
2742		// re-pointed on slide change. This IS the commit moment — TikTok swaps
2743		// its counts in the paint the commit lands in, and a mid-transition
2744		// swap would put the incoming post's figures over the outgoing post's
2745		// video for the length of the transition.
2746		updateRail(post);
2747
2748		// §2.3's end clamp, made visible (rule 1.11's `disabled` at the ends).
2749		// navigate() already refuses an out-of-range target, so this is
2750		// presentational and assistive rather than functional — but a chevron
2751		// that looks live and does nothing is the §6.2 defect class applied to
2752		// position instead of capability. Also drops the button out of the Tab
2753		// cycle via focusables()' :not([disabled]) filter, so keyboard users
2754		// don't land on a dead stop at either end of the track.
2755		$navPrev.prop('disabled', currentIndex <= 0);
2756		$navNext.prop('disabled', currentIndex >= posts.length - 1);
2757
2758		updateMuteChrome();
2759		syncPlayState();
2760	}
2761
2762	// §7.1's polite position announcement (rule 1.8). Guarded against
2763	// re-writing an unchanged string for the same reason announcePlayState() is:
2764	// a live region re-announces on every write, so an idempotent write is an
2765	// audible repetition rather than a no-op.
2766	function announceLive(text) {
2767		if (!$live || !$live.length) return;
2768		if ($live[0].textContent === text) return;
2769		$live[0].textContent = text; // text node — never markup (§12).
2770	}
2771
2772	// Activates (or re-activates, after a fallback escalation) the slide at
2773	// `index`: lazily builds its media on first visit, plays it, and pauses
2774	// every other slide WITHOUT destroying it (§4.4 — off-screen slides are
2775	// paused, not torn down, so a one-step back-swipe is instant).
2776	function activateSlide(index, reactivateOnly) {
2777		ensureSlideMedia(index);
2778		slides.forEach(function (slide, i) {
2779			if (!slide.inst) return;
2780			if (i === index) {
2781				// §4.1/§4.2: play({ muted }) mirrors setMuted(bool) -- muted:true
2782				// means "play muted". Passing userMuted directly (not negated)
2783				// is what makes an explicit unmute persist across a swipe; the
2784				// negated form used to re-mute every newly-activated slide.
2785				slide.inst.play({ muted: userMuted });
2786				armWatchdog(slide, i);
2787			} else if (!reactivateOnly) {
2788				clearWatchdog(slide);
2789				slide.inst.pause();
2790			}
2791		});
2792		updateChrome();
2793		syncCaptionExpansion(index);
2794		// Rule 1.14: a slide activated from a pre-warm may already have readable
2795		// intrinsic dimensions, in which case no `loadedmetadata` is coming and
2796		// the fit has to be decided here instead.
2797		applyFit(slides[index]);
2798		// Rule 1.6/1.7b: the label REPLAYS on each new slide while the viewer is
2799		// still muted because sound was refused, and stops forever once the
2800		// visitor unmutes. Calling it from the one place that means "a slide
2801		// became active" is what makes "per slide" structural.
2802		startSoundHint();
2803		// §4.4 adjacent pre-warm (SMASH-1979) — LAST, after the active slide has been
2804		// asked to play. Ordering is the cheap-but-real kind: the active slide's own
2805		// request goes out first, so a warm fetch never queues ahead of the media the
2806		// visitor is actually looking at. Also runs the release half, so a slide that
2807		// just left the ±1 window is reclaimed on the same activation that moved the
2808		// window past it.
2809		prewarmAdjacent(index);
2810	}
2811
2812	// ── Caption expansion lifecycle (§6.1, rules IG-4/IG-5) ──────────────────
2813	// Two things changed with the design's ONE-LINE clamp, and the first is easy
2814	// to get wrong: overflow is now a WIDTH question, not a height one. The
2815	// three-line clamp used `-webkit-line-clamp`, so overflow showed up as
2816	// scrollHeight > clientHeight; a single line with `white-space: nowrap` +
2817	// ellipsis overflows HORIZONTALLY, so the only measurement that detects it is
2818	// scrollWidth > clientWidth. Keeping the height comparison would have left
2819	// "more" permanently hidden and silently truncated every long caption — the
2820	// exact class of silent text loss spec §4.1 item 9 measured in the reference.
2821	//
2822	// The reveal is also driven by a CLASS on the caption rather than the
2823	// button's `hidden` attribute. `hidden` on a flex item is honoured, but the
2824	// button then also has to be measured to decide whether the text has room
2825	// for it, and the two-way dependency (button visible -> less width for text
2826	// -> maybe no overflow -> hide button) oscillates. Measuring the TEXT node's
2827	// own overflow while the button is display:none breaks the cycle: the answer
2828	// no longer depends on the answer.
2829	//
2830	// §6.1's "expansion never survives a slide change" is enforced by sweeping
2831	// every non-active slide on every activation, which is what the reference's
2832	// IG skin fails to do (measured: aria-expanded still true after away-and-back)
2833	// while his own YouTube skin gets right.
2834	function syncCaptionExpansion(activeIndex) {
2835		slides.forEach(function (slide, i) {
2836			if (!slide.article) return;
2837			var cap = slide.article.querySelector('.sbi-qs-caption');
2838			var text = slide.article.querySelector('.sbi-qs-caption-text');
2839			var btn = slide.article.querySelector('.sbi-qs-caption-more');
2840			if (!cap || !text || !btn) return;
2841			if (i !== activeIndex) {
2842				cap.classList.remove('sbi-qs-caption-expanded');
2843				text.scrollTop = 0;
2844				btn.setAttribute('aria-expanded', 'false');
2845				btn.textContent = 'more';
2846				return;
2847			}
2848			// Active slide: reveal the toggle only for real overflow, measured in
2849			// the clamped state. An already-expanded caption is left alone on
2850			// reactivateOnly passes — re-measuring an expanded block would find
2851			// no horizontal overflow and hide the only way back.
2852			if (!cap.classList.contains('sbi-qs-caption-expanded')) {
2853				if (text.scrollWidth > text.clientWidth + 1) {
2854					cap.classList.add('sbi-qs-caption-has-more');
2855				} else {
2856					cap.classList.remove('sbi-qs-caption-has-more');
2857				}
2858			}
2859		});
2860	}
2861
2862	// ── Mute / play-pause ───────────────────────────────────────────────────
2863	// Rex-class review demand (mirrors SMASH-1853): this must flip based on
2864	// the REAL sampled state (realMuted()), not the `userMuted` intent.
2865	// After a rejected attemptPlay() retry falls back to muted, intent
2866	// never flips (by design — §4.2's fallback is per-attempt, not a
2867	// persisted choice), so the button's own label already correctly says
2868	// "Unmute" at that point (driven by updateMuteChrome() above). Acting
2869	// on the same real state the label reads from is what keeps the label
2870	// and the action in agreement — toggling off the STALE intent instead
2871	// would silently mute a button that visibly says "Unmute".
2872	function toggleMute() {
2873		var slide = slides[currentIndex];
2874		if (!slide || !slide.caps || !slide.caps.mute) return;
2875		var shouldUnmute = realMuted();
2876		userMuted = !shouldUnmute; // record the new explicit choice for future activations (§4.2).
2877		if (slide.inst && slide.inst.setMuted) slide.inst.setMuted(!shouldUnmute);
2878		// An explicit unmute is rule 1.7b's "stops forever"; an explicit mute is
2879		// rule 1.7c's "respect the choice". Either way the label must not be out
2880		// after the visitor has touched this control, and stopSoundHint() is
2881		// idempotent so calling it on both legs is simpler than branching.
2882		stopSoundHint();
2883		// ── DELIBERATELY DOES NOT record the unmute as SUCCEEDED ─────────────
2884		// This line used to read `if (shouldUnmute) noteUnmuteSucceeded();` and
2885		// that was a real defect, found by the live refusal-arm proof: a TAP is
2886		// not a success. Where autoplay is refused, tapping the chip calls
2887		// setMuted(false) -> attemptPlay(muted: false), which the browser can
2888		// refuse just as it refused the first attempt — and the element falls
2889		// back to muted. Recording success on the tap would then set the
2890		// "never label again" flag on a viewer that is still silent, removing
2891		// the only affordance the visitor has left. It is the exact failure the
2892		// pill's own handler was written to avoid, re-introduced when the pill
2893		// was folded into this button.
2894		//
2895		// noteUnmuteSucceeded() has exactly ONE writer, and it is attemptPlay()'s
2896		// resolve handler, gated on the element ACTUALLY being unmuted at resolve
2897		// time (`p.then(function () { if (!v.muted) … })`). A successful tap goes
2898		// through that path too, so nothing is lost by staying out of it — the
2899		// flag is set by reality rather than by intent.
2900		updateChrome();
2901		dismissCoach();
2902		emitAnalytics(shouldUnmute ? 'swipeview_unmuted' : 'swipeview_muted', { index: currentIndex });
2903	}
2904
2905	// ── Pause glyph (rule 1.10) ──────────────────────────────────────────────
2906	// REPLACES the 600ms tap-flash. The glyph is no longer an echo of a gesture —
2907	// it is a STATE readout: a play triangle shown for as long as the slide is
2908	// paused, however it got paused (tap, Space, or a stalled tier). That is
2909	// strictly more honest than the flash, which fired on the tap and then
2910	// vanished whether or not the video actually stopped.
2911	//
2912	// Consequence worth naming: the flash was also the confirmation for
2913	// UNPAUSING, and a state readout cannot be — there is no paused state left
2914	// to show. Rule 1.10's asymmetric dissolve is what covers that: the glyph
2915	// animates out over 100ms rather than disappearing, so a resume still gets a
2916	// visible acknowledgement. `.sbi-qs-flash-on` is the one-shot class that
2917	// keeps it displayed for exactly that window.
2918	//
2919	// Driven off the root's `data-paused` attribute, written from the SAME
2920	// 250ms-sampled real player state syncPlayState() announces. One source of
2921	// truth for "is it paused": a separate class toggled by the tap handler would
2922	// be a second, and this file's history is full of two-source-of-truth bugs.
2923	function setPaused(paused) {
2924		if (!$container || !$container.length) return;
2925		var was = $container.attr('data-paused') === 'true';
2926		if (was === paused) return;
2927		$container.attr('data-paused', paused ? 'true' : 'false');
2928		if (flashOffTimer) { clearTimeout(flashOffTimer); flashOffTimer = null; }
2929		if (paused) {
2930			$flash.removeClass('sbi-qs-flash-on');
2931			return;
2932		}
2933		// Unpausing: hold the glyph displayed for exactly the dissolve's length,
2934		// then drop it.
2935		$flash.addClass('sbi-qs-flash-on');
2936		flashOffTimer = setTimeout(function () {
2937			flashOffTimer = null;
2938			$flash.removeClass('sbi-qs-flash-on');
2939		}, FLASH_OUT_MS);
2940	}
2941
2942	// Cleared on open/close/navigate as well as by its own timer — a glyph left
2943	// over from the slide being swiped away describes a playback state on a video
2944	// the visitor is no longer looking at.
2945	function clearFlash() {
2946		if (flashOffTimer) { clearTimeout(flashOffTimer); flashOffTimer = null; }
2947		if ($flash && $flash.length) $flash.removeClass('sbi-qs-flash-on');
2948		if ($container && $container.length) $container.attr('data-paused', 'false');
2949	}
2950
2951	// ── First-run swipe coach (rule 1.9) ─────────────────────────────────────
2952	// Touch only, first run only. Both gates are load-bearing: desktop has
2953	// visible chevrons and needs no swipe instruction (and the CSS makes the same
2954	// distinction with the same capability query, so the two cannot disagree
2955	// about which device they are on), and "once ever" is what separates a coach
2956	// mark from the instructional overlay Aman deliberately removed.
2957	//
2958	// localStorage is wrapped because it THROWS rather than returning null in
2959	// Safari's private mode and under a blocked-site-data policy. A throw there
2960	// must not take the viewer down with it, and the safe direction on failure is
2961	// to SHOW the coach: a visitor whose browser cannot persist the flag is
2962	// better served by seeing a one-line hint again than by never seeing it.
2963	function coachAlreadySeen() {
2964		try {
2965			return !!(window.localStorage && window.localStorage.getItem(COACH_STORAGE_KEY) === '1');
2966		} catch (e) { return false; }
2967	}
2968
2969	function markCoachSeen() {
2970		try {
2971			if (window.localStorage) window.localStorage.setItem(COACH_STORAGE_KEY, '1');
2972		} catch (e) { /* private mode / blocked storage — nothing to re
2972cord */ }
2973	}
2974
2975	function maybeShowCoach() {
2976		if (hasKeyboardAffordance()) return;   // desktop: the chevrons already say it
2977		if (coachAlreadySeen()) return;
2978		if (!$coach || !$coach.length) return;
2979		$coach.addClass('sbi-qs-coach-visible');
2980		markCoachSeen();
2981		// Dismiss listeners go live 350ms AFTER open, so the very tap that opened
2982		// the viewer cannot dismiss the overlay before it has been read (rule 1.9).
2983		coachArmed = false;
2984		coachArmTimer = setTimeout(function () { coachArmTimer = null; coachArmed = true; }, COACH_ARM_MS);
2985		coachOffTimer = setTimeout(dismissCoach, COACH_AUTO_MS);
2986	}
2987
2988	// Called from every input that means "the visitor is now driving" — the same
2989	// call sites the intro card's dismissHint() had, minus the ones that no
2990	// longer exist. Named `dismissCoach` rather than kept as `dismissHint`
2991	// because "the hint" used to mean the four-row card, and a reader coming from
2992	// the old code should not be able to mistake one for the other.
2993	function dismissCoach() {
2994		if (coachOffTimer) { clearTimeout(coachOffTimer); coachOffTimer = null; }
2995		// Before the arm delay elapses a dismiss request is DEFERRED rather than
2996		// obeyed: the whole point of the delay is that the opening tap does not
2997		// count, and dropping the request instead of re-scheduling it would leave
2998		// the overlay up until the auto-dismiss.
2999		if (!coachArmed && $coach && $coach.hasClass('sbi-qs-coach-visible')) {
3000			coachOffTimer = setTimeout(dismissCoach, COACH_ARM_MS);
3001			return;
3002		}
3003		if (coachArmTimer) { clearTimeout(coachArmTimer); coachArmTimer = null; }
3004		coachArmed = false;
3005		if ($coach && $coach.length) $coach.removeClass('sbi-qs-coach-visible');
3006	}
3007
3008	// §6.1's loading affordance (rule 1.19). An attribute on the frame rather
3009	// than a class on the spinner: the spinner is one of several things the
3010	// frame's state drives, and one attribute keeps them from disagreeing.
3011	function setBuffering(slide, on) {
3012		if (!slide || !slide.frame) return;
3013		if (on) {
3014			slide.frame.setAttribute('data-buffering', 'true');
3015		} else {
3016			slide.frame.removeAttribute('data-buffering');
3017		}
3018	}
3019
3020	// ── Smooth progress interpolation (maintainer direction 2026-08-27) ──────
3021	//
3022	// THE PROBLEM. The bar was written straight from tick(), i.e. once every
3023	// CHROME_POLL_MS, so it advanced in visible 250ms steps — four jerks a second
3024	// against a video that is obviously continuous.
3025	//
3026	// THE MECHANISM, taken from SMASH-1853: keep ONE sampling point at the §6.1
3027	// cadence and let CSS interpolate between samples, rather than adding a
3028	// second, faster sampler. That matters beyond effort — a rAF loop reading
3029	// currentTime would be a second source of truth for "where is playback",
3030	// running at 60Hz next to a 4Hz one, and this file's history is full of
3031	// exactly that kind of drift. The 250ms poll stays the only reader.
3032	//
3033	// TWO DEVIATIONS from the reference, both deliberate:
3034	//
3035	//   1. `transform: scaleX()`, not `width`. SMASH-1853 transitions `width`,
3036	//      which is a LAYOUT property: every frame of a continuously-running
3037	//      transition costs a layout pass for the life of the viewer. scaleX is
3038	//      compositor-only. Same visual result, and it is the one property choice
3039	//      the brief calls out.
3040	//   2. The transition duration MATCHES the poll interval exactly (the
3041	//      reference uses 200ms against a 250ms poll, leaving a 50ms hold at the
3042	//      end of every cycle). Matching means each sample's interpolation ends
3043	//      precisely as the next arrives, so the motion is continuous rather than
3044	//      advance-hold-advance. A test asserts the two numbers agree.
3045	//
3046	// The cost of interpolating toward the LAST SAMPLE is that the bar trails real
3047	// playback by up to one poll interval. That is inherent to this mechanism and
3048	// invisible at 250ms on a progress readout; it is the trade being made, not an
3049	// oversight.
3050	//
3051	// `lastProgressFraction` exists for the discontinuity test below, and is
3052	// `null` when nothing has been written yet — distinct from 0, which is a real
3053	// position.
3054	var lastProgressFraction = null;
3055	// A backward move larger than this is a DISCONTINUITY (a loop back to the
3056	// start, a slide change, a re-instantiated tier), not playback. Interpolating
3057	// through one animates a rewind the visitor never performed — the single most
3058	// visible way this feature can go wrong. Small enough that ordinary jitter in
3059	// `currentTime` never trips it, large enough to catch any real reset.
3060	var PROGRESS_JUMP_EPSILON = 0.02;
3061
3062	function setProgress(fraction, forceJump) {
3063		var node = $progressFill && $progressFill[0];
3064		if (!node) return;
3065		// Clamp defensively. `duration` can be NaN or Infinity on a stream, and
3066		// `!(f >= 0)` catches NaN where `f < 0` would not.
3067		var f = fraction;
3068		if (!(f >= 0)) f = 0;
3069		if (f > 1) f = 1;
3070
3071		// Forward motion interpolates; anything else lands instantly.
3072		var jump = !!forceJump || lastProgressFraction === null || f < lastProgressFraction - PROGRESS_JUMP_EPSILON;
3073		lastProgressFraction = f;
3074
3075		// ORDER AND SAME-TASK are both load-bearing. A transition starts only if
3076		// the AFTER-change computed style still declares one, so toggling the
3077		// no-transition class and writing the transform in this one task is what
3078		// makes the jump instant — and removing the class on the next write is what
3079		// lets the following sample ease from wherever the jump landed. Split these
3080		// across tasks (a rAF, a timeout) and the jump animates instead.
3081		$progressFill.toggleClass('sbi-qs-progress-fill-jump', jump);
3082		node.style.transform = 'scaleX(' + f + ')';
3083	}
3084
3085	// §6.1's play-state readout for assistive tech, replacing the removed
3086	// button's own accessible name.
3087	//
3088	// The `lastPlayStateText` guard is REQUIRED, not tidiness. This is called from
3089	// tick(), i.e. four times a second, and assigning textContent replaces the
3090	// text node even when the string is identical — a mutation several screen
3091	// readers announce. Without the guard a paused video would say "Paused" every
3092	// 250ms for as long as the viewer is open. SMASH-1853 gets this for free
3093	// because React diffs the value; an imperative updater has to do it by hand.
3094	function announcePlayState(text) {
3095		if (text === lastPlayStateText) return;
3096		lastPlayStateText = text;
3097		if ($playState && $playState.length) $playState[0].textContent = text; // text node — never markup (§12).
3098	}
3099
3100	// Reads the ACTIVE slide's real playing state and its capability, and keeps
3101	// the live region in step. One place, called from updateChrome() and tick()
3102	// — the two updaters that used to swap the button's glyph and label.
3103	function syncPlayState() {
3104		var slide = slides[currentIndex];
3105		var caps = (slide && slide.caps) || NO_CAPS;
3106		if (!caps.playPause || !slide || !slide.inst || !slide.inst.isPlaying) {
3107			// §6.2: silent rather than claiming a state no control can honour.
3108			announcePlayState('');
3109			// A tier with no play/pause capability has no paused state to
3110			// report, so rule 1.10's glyph must not claim one either.
3111			setPaused(false);
3112			return;
3113		}
3114		// Sampled ONCE and held, then read by both channels. Rule 1.10's glyph
3115		// and §6.1's live region are two renderings of one fact, and sampling
3116		// twice is how they come to disagree — the same one-sample discipline
3117		// togglePlay() follows for its own branch.
3118		var playing = slide.inst.isPlaying();
3119		announcePlayState(playing ? 'Playing' : 'Paused');
3120		setPaused(!playing);
3121	}
3122
3123	// ── Desktop chevron nav (2026-08-27) ─────────────────────────────────────
3124	// Reads its direction off `data-nav` so ONE handler serves both buttons,
3125	// matching the `data-act` convention the mute button already uses.
3126	//
3127	// It deliberately does nothing except dismiss the hint and call navigate() —
3128	// the same two statements the ArrowUp/ArrowDown branches of onKeydown run. Any
3129	// extra step here (its own cooldown, its own clamp, its own emit) would be a
3130	// second definition of "advance one slide" that could drift from the keyboard
3131	// one; a test pins the two paths as producing identical navigate() calls.
3132	//
3133	// No preventDefault: unlike the key handler there is no default scroll or
3134	// page action to suppress on a <button type="button"> click.
3135	function onNavClick(e) {
3136		var btn = e && (e.currentTarget || e.target);
3137		var dir = btn && btn.getAttribute ? btn.getAttribute('data-nav') : null;
3138		// Anything other than the two known values is ignored rather than guessed
3139		// at — a mistyped attribute must not silently navigate the wrong way.
3140		if (dir !== 'prev' && dir !== 'next') return;
3141		dismissCoach();
3142		navigate(dir === 'next' ? 1 : -1);
3143	}
3144
3145	// The painted stage column, read off the live element. Deliberately NOT
3146	// recomputed from `min(100vw, 100dvh * 9 / 16)`: that expression already
3147	// exists in the stylesheet (see .sbi-qs-media), a media query is free to
3148	// override it, and two copies of a geometry rule drift. .sbi-qs-media is built
3149	// for every slide regardless of tier, so this is defined on the embed tier too
3150	// — which matters below, because a tier-dependent close region would be a
3151	// worse bug than the one being fixed.
3152	// The PAINTED FRAME's rect, not the media host's. The two resolve to the same
3153	// box today — the host is `inset: 0` inside the frame — so this is a
3154	// correctness-of-intent change rather than a behaviour change: what
3155	// "backdrop" means is "outside the picture", and the picture is the frame.
3156	// Reading the host would start lying the moment the host gains an inset of
3157	// its own, and it is the frame that the 8px desktop margins are measured
3158	// from (rule 1.1).
3159	function stageRect() {
3160		var slide = slides[currentIndex];
3161		if (!slide || !slide.frame) return null;
3162		var box = slide.frame.getBoundingClientRect();
3163		return (box.width > 0 && box.height > 0) ? box : null;
3164	}
3165
3166	function pointInRect(x, y, box) {
3167		return !!box && x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
3168	}
3169
3170	// §2 uniform grammar (maintainer direction 2026-08-31): a click on the black
3171	// surround closes the viewer, as TikTok's does. Everything inside the stage
3172	// column keeps the tap-to-pause toggle.
3173	//
3174	// The boundary is the STAGE COLUMN, not the painted media frame inside it.
3175	// Those differ whenever the source is not exactly 9:16 — object-fit: contain
3176	// leaves a band above and below the picture (the same band
3177	// letterboxBottomBand() measures for the hint card) — and using the painted
3178	// frame was rejected on two counts:
3179	//   - The bottom band is where the chrome lives. The caption inherits
3180	//     pointer-events: none, so a near-miss on it reaches this handler, and
3181	//     closing the viewer because someone aimed slightly wide of a caption is
3182	//     a worse defect than the one being fixed.
3183	//   - It would be tier-dependent. The embed tier is a cross-origin iframe with
3184	//     no measurable intrinsic size, so letterboxBottomBand() returns 0 there
3185	//     and the "painted frame" IS the stage box. Identical-looking layouts
3186	//     would then close in different places depending on which tier the post
3187	//     resolved to, which is not a grammar anyone can learn.
3188	// The stage column is the same box on every tier and has no chrome inside it
3189	// at all (see .sbi-qs-media's comment), so it is the one boundary that is both
3190	// unambiguous and uniform. On phones the column resolves to 100vw, so there is
3191	// no surround and this branch never fires — correct, since a full-bleed viewer
3192	// has no backdrop to click.
3193	//
3194	// ONE CARVE-OUT: the chevron cluster. .sbi-qs-nav sits 60px OUTSIDE the
3195	// column's edge, and its container is deliberately pointer-events: none so the
3196	// 12px gap between the two discs does not swallow swipe-starts (§6.3). That
3197	// made the gap fall through to a harmless tap-to-pause; letting it fall through
3198	// to close() would turn a 12px vertical near-miss on "next" into losing your
3199	// place in the track. The cluster's own box is therefore excluded. It needs no
3200	// visibility check: the cluster is display: none on touch viewports, and a
3201	// display: none element reports a 0x0 rect that contains no point, so the
3202	// carve-out self-disables exactly where the chevrons do not exist.
3203	//
3204	// Every genuine control (close, mute, chevrons, unmute pill, caption expander)
3205	// declares pointer-events: auto and so is never seen by this handler at all —
3206	// their clicks are unaffected by any of this.
3207	// ── Control dead space (cross-viewer alignment, 2026-09-10) ───────────────
3208	// The rail column and the chevron cluster, as BOUNDING BOXES rather than as
3209	// their individual controls. A point in here is inside a region the visitor
3210	// reads as "the controls" but is not on one of them — the 18px gaps between
3211	// rail glyphs, the rail's 18px of bottom padding, the 12px between the two
3212	// chevron discs.
3213	//
3214	// Such a point now does NOTHING: it neither dismisses the viewer nor toggles
3215	// playback. That is an orchestrator call for the uniform contract, made
3216	// after Facebook reproduced Asmita's "the like icon acts as play/pause"
3217	// report as a NEAR-MISS — a tap in the 16px strip between its rail items
3218	// fell through and paused the reel. FB now has `pointIsInControlDeadSpace()`
3219	// and Instagram mirrors it.
3220	//
3221	// It REVERSES a conclusion I recorded two commits ago, and the reversal is
3222	// the interesting part, so it is worth writing down rather than quietly
3223	// overwriting: I had argued that a carve-out's job is "do not dismiss" and
3224	// never "do not pause", on the grounds that dismissal is destructive and
3225	// unrecoverable while pausing is neither. That reasoning is sound about
3226	// CONSEQUENCE and wrong about INTENT. Someone aiming at a 26px bare glyph
3227	// and missing it by eight pixels did not intend to pause the reel; they
3228	// intended to hit the glyph. Sizing the response to the damage rather than
3229	// to the evident intent is what made an 18px gap behave like the middle of
3230	// the video, and "harmless" is not the same as "wanted".
3231	//
3232	// The MEDIA still toggles on tap, which is the whole point of the
3233	// distinction: dead space is the gaps inside a control cluster, not the
3234	// picture.
3235	// A `display: none` element reports a 0x0 rect, and pointInRect() is
3236	// inclusive on all four edges — so a degenerate rect sitting at the origin
3237	// "contains" the point (0, 0). Harmless for a cluster positioned out at
3238	// x 954, which is why it went unnoticed; not harmless on a touch viewport,
3239	// where a hidden cluster's rect really is 0x0 at 0,0 and (0, 0) is the
3240	// frame's own top-left corner. A hidden cluster must carve out nothing at
3241	// all, which is what the self-disable checks have always claimed.
3242	function rectHasArea(box) {
3243		return !!box && box.width > 0 && box.height > 0;
3244	}
3245
3246	function pointIsInControlDeadSpace(x, y) {
3247		// Session-level, so it hangs off the container.
3248		var nav = $container.find('.sbi-qs-nav').get(0);
3249		var navBox = nav ? nav.getBoundingClientRect() : null;
3250		if (rectHasArea(navBox) && pointInRect(x, y, navBox)) return true;
3251		// ALSO session-level since SMASH-1851, and this lookup INVERTED with
3252		// that change: it used to have to start from `slides[currentIndex]`
3253		// because a container-wide query would return whichever slide's column
3254		// came first in the DOM — the wrong box, and one that moved under a
3255		// drag. There is exactly one column now, so the container query is the
3256		// only correct one and the per-slide query would throw or find nothing.
3257		//
3258		// `getBoundingClientRect()` stays the measurement, and now for a second
3259		// reason as well as the first: it already accounts for the rail's own
3260		// drag-follow transform (setRailTravel), so the carve-out tracks the
3261		// finger during a drag instead of guarding the resting rect.
3262		var railcol = $container.find('.sbi-qs-railcol').get(0);
3263		var railBox = railcol ? railcol.getBoundingClientRect() : null;
3264		if (rectHasArea(railBox) && pointInRect(x, y, railBox)) return true;
3265		return false;
3266	}
3267
3268	function clickIsOnBackdrop(x, y) {
3269		var stage = stageRect();
3270		if (!stage) return false;
3271		if (pointInRect(x, y, stage)) return false;
3272		// The near-miss carve-outs, now shared with the pause path above rather
3273		// than restated here. Sharing the predicate is what stops dismissal and
3274		// pausing disagreeing about where the controls are — they were two
3275		// separate opinions for exactly one commit, and that was one too many.
3276		//
3277		// On desktop both clusters sit OUTSIDE the frame (the rail column at
3278		// x 954.6-998.6 against a frame ending at 938.7), so without this every
3279		// pixel of them would be backdrop. On touch the rail is an in-frame
3280		// overlay and the frame check above already covers it.
3281		if (pointIsInControlDeadSpace(x, y)) return false;
3282		return true;
3283	}
3284
3285	function onGestureClick(e) {
3286		if (suppressTap) {
3287			suppressTap = false;
3288			return;
3289		}
3290		// A click with no usable coordinates (a programmatic .click(), a synthetic
3291		// event) falls through to the toggle rather than guessing at a position —
3292		// again the fail-safe direction, since one of the two outcomes is
3293		// destructive and the other is not.
3294		if (e && typeof e.clientX === 'number' && typeof e.clientY === 'number') {
3295			// Dead space FIRST, and the order is the mechanism: a point inside a
3296			// control cluster but on no control must fall out here before either
3297			// action is considered, so neither the dismiss branch nor the toggle
3298			// below can claim it. Checking it after clickIsOnBackdrop() would
3299			// work only because that function happens to carve the same rects —
3300			// this way the two cannot drift.
3301			if (pointIsInControlDeadSpace(e.clientX, e.clientY)) return;
3302			if (clickIsOnBackdrop(e.clientX, e.clientY)) {
3303				close();
3304				return;
3305			}
3306		}
3307		togglePlay();
3308	}
3309
3310	function togglePlay() {
3311		var slide = slides[currentIndex];
3312		if (!slide || !slide.inst) return;
3313		dismissCoach();
3314		if (slide.caps && !slide.caps.playPause) return; // Disabled, never silently inert (§6.2).
3315		// Resolved ONCE, before anything acts on it, and reused for both the flash
3316		// and the branch below. Sampling `isPlaying()` twice — once to pick the
3317		// glyph and once to pick the action — is how the picture and the behaviour
3318		// come to disagree: pause() and play() both change what the second read
3319		// would return.
3320		var willPlay = !(slide.inst.isPlaying && slide.inst.isPlaying());
3321		// Rule 1.10: there is no flash to fire here any more. The glyph is a
3322		// STATE readout now, so updateChrome() -> syncPlayState() at the end of
3323		// this function is what shows or dissolves it — off the REAL post-toggle
3324		// state rather than off the intent this handler is acting on. That also
3325		// retires the §6.2 concern the old call site carried: a tier with no
3326		// playPause capability reports no paused state, so nothing can be
3327		// promised that the tier cannot honour.
3328		if (!willPlay) {
3329			clearWatchdog(slide);
3330			slide.inst.pause();
3331		} else {
3332			// Same fix as activateSlide() above -- pass userMuted, not its negation.
3333			slide.inst.play({ muted: userMuted });
3334			armWatchdog(slide, currentIndex);
3335		}
3336		updateChrome();
3337	}
3338
3339	// ── Onboarding hint ─────────────────────────────────────────────────────
3340	// ── Wheel (rule 1.16 — the reference's tuning, adopted) ──────────────────
3341	// The old shape fired on any single event whose |deltaY| cleared 25, then
3342	// locked for 550ms. On a trackpad that reads the INERTIA TAIL as fresh
3343	// intent: one flick emits a long decaying burst, several events of which
3344	// clear any per-event threshold, so once the lock expired mid-tail the
3345	// remaining momentum moved another slide. Aman's commit for this says it
3346	// plainly — "one slide per gesture, inertia tail ignored" — and the video
3347	// notes flag it as non-obvious and easy to lose.
3348	//
3349	// The replacement accumulates, and the three numbers work as a 
3349set:
3350	//   - the accumulator RESETS when the gap since the last event exceeds
3351	//     WHEEL_GAP_MS. That gap is what "the gesture ended" actually looks
3352	//     like; a decaying tail keeps firing inside it, a deliberate second
3353	//     scroll does not.
3354	//   - one slide fires when |acc| clears WHEEL_THRESHOLD, and the accumulator
3355	//     is zeroed so the rest of the same gesture cannot add to it.
3356	//   - after firing, nothing else fires until BOTH a real gap has been seen
3357	//     AND WHEEL_MIN_INTERVAL_MS has passed. The AND is the point: a time
3358	//     lock alone is what let the tail through, and a gap check alone would
3359	//     let a fast deliberate double-scroll outrun the transition.
3360	//
3361	// `ctrlKey` is ignored because that is a pinch-zoom on a trackpad, not a
3362	// scroll — hijacking it would break the browser's own zoom inside a viewer
3363	// that has already taken over the page.
3364	function onWheel(e) {
3365		e.preventDefault();
3366		if (e.ctrlKey) return;
3367		var now = Date.now();
3368		var gap = now - wheelLastAt;
3369		if (gap > WHEEL_GAP_MS) {
3370			wheelAcc = 0;
3371			// A genuine gap is also what releases the post-fire lock.
3372			wheelLock = false;
3373		}
3374		wheelLastAt = now;
3375		if (wheelLock) return;
3376		if (now - wheelFiredAt < WHEEL_MIN_INTERVAL_MS) return;
3377		wheelAcc += e.deltaY;
3378		if (Math.abs(wheelAcc) < WHEEL_THRESHOLD) return;
3379		var dir = wheelAcc > 0 ? 1 : -1;
3380		wheelAcc = 0;
3381		wheelLock = true;
3382		wheelFiredAt = now;
3383		dismissCoach();
3384		navigate(dir);
3385	}
3386
3387	function resetWheel() {
3388		wheelAcc = 0;
3389		wheelLastAt = 0;
3390		wheelFiredAt = 0;
3391		wheelLock = false;
3392	}
3393
3394	// ── Thumb-follow drag (SMASH-1978) ──────────────────────────────────────
3395
3396	// One slide's height in px — the unit both the clamp and the area rule are
3397	// expressed in. Measured off the live element rather than derived from
3398	// window.innerHeight: the post IS the thing being translated, its height is
3399	// the `100dvh` the stylesheet resolved, and offsetHeight is unaffected by the
3400	// track's transform. innerHeight is only the fallback for a slide that has
3401	// not been laid out yet.
3402	function slideHeightPx() {
3403		var slide = slides[currentIndex];
3404		var h = (slide && slide.article) ? slide.article.offsetHeight : 0;
3405		return h > 0 ? h : (window.innerHeight || 0);
3406	}
3407
3408	// §2.3 clamp, applied to the LIVE drag rather than only to the commit. Two
3409	// separate limits, both normative rather than cosmetic:
3410	//
3411	//  1. Ends of the track. At the first slide the track may not move DOWN and
3412	//     at the last it may not move UP, because there is nothing there — the
3413	//     spec makes the clamp normative and the bounce only a MAY, and this
3414	//     file's own history removed an edge bounce for juddering. Letting the
3415	//     drag path reveal empty space would reintroduce that overshoot by the
3416	//     back door, so the clamp lives here, not in the release handler.
3417	//
3418	//  2. One slide per gesture. The delta is capped at a single slide height so
3419	//     a long drag can never show slide+2 and then snap back to slide+1. This
3420	//     is the one place the follow stops being strictly 1:1 with the finger,
3421	//     and it is deliberate: 1:1 holds throughout the range a gesture can
3422	//     actually commit to, and native players clamp the same way.
3423	function clampDragDelta(fingerDy) {
3424		var h = slideHeightPx();
3425		var d = fingerDy;
3426		if (h > 0) d = Math.max(-h, Math.min(h, d));
3427		// Dragging up (negative) reveals the NEXT slide; impossible at the end.
3428		// §2.3's strict clamp: no wrap, no rubber-band, a hard no-op.
3429		if (dragBaseIndex >= posts.length - 1 && d < 0) return 0;
3430		// Dragging down (positive) reveals the PREVIOUS slide; impossible at 0.
3431		if (dragBaseIndex <= 0 && d > 0) {
3432			// ── Pull-to-close's ONLY concession to the clamp (rule 1.16) ─────
3433			// This used to be a hard 0 — the track did not move at all.
3434			// Pull-to-close needs the gesture to be legible WHILE it happens, so
3435			// the track now follows a little and then returns: a fraction of the
3436			// finger's travel, capped just past the release threshold.
3437			//
3438			// Deliberately NOT a 1:1 follow, and that is the whole difference
3439			// between this and the rubber-band bounce that was removed at
3440			// da3aeb4a. The ratified clamp feel is "the track does not move
3441			// where there is nothing to move to"; a damped, capped follow reads
3442			// as RESISTANCE rather than as travel, so it acknowledges the
3443			// gesture without implying there is a slide above. The strict clamp
3444			// at the LAST slide is untouched, because nothing is bound to it.
3445			return Math.min(d * PULL_FOLLOW_RATIO, PULL_CLOSE_PX + 20);
3446		}
3447		return d;
3448	}
3449
3450	// THE one place tap suppression is captured (2026-08-27). Every path that ends
3451	// a gesture routes through here, which is what makes "a drag never toggles
3452	// playback" a property of ending a drag rather than something each call site
3453	// has to remember — see `suppressTap` for the defect that shape prevents.
3454	function resetDrag() {
3455		suppressTap = dragging;
3456		touchStartY = null;
3457		dragging = false;
3458		dragDeltaPx = 0;
3459	}
3460
3461	// Return the track to the slide it started from, animated (or not, under
3462	// reduced motion — the release snap is where §3.2 applies; the follow-drag
3463	// itself is direct manipulation and is never animated either way).
3464	function settleBack() {
3465		var instant = prefersReducedMotion();
3466		$track.css('transition', instant ? 'none' : 'transform ' + TRANSITION_MS + 'ms cubic-bezier(0.22, 0.61, 0.36, 1)');
3467		setTrackOffset(-currentIndex * 100, 0);
3468		// The ONE caller that animates the rail. A drag that did not commit
3469		// returns to rest, and the rail returns with the track over the same
3470		// duration and the same easing — the release snap is exactly where §3.2
3471		// applies, so setRailTravel() drops it under reduced motion.
3472		setRailTravel(0, !instant);
3473	}
3474
3475	function onTouchStart(e) {
3476		// Single finger only (2026-08-27, mirrors SMASH-1853). A second touch is a
3477		// pinch or a stray palm, and this handler used to read `e.touches[0]`
3478		// unconditionally — so a second finger landing mid-drag re-based the
3479		// gesture on that finger's Y and the track lurched by the distance between
3480		// the two contacts.
3481		//
3482		// Ending the drag here also captures the tap suppression (resetDrag does
3483		// it), which is the half SMASH-1853 shipped broken: an aborted pinch that
3484		// had already travelled is not a tap, but it can still deliver a
3485		// synthesised click, and that click used to toggle playback.
3486		if (!e.touches || e.touches.length !== 1) {
3487			if (touchStartY !== null) {
3488				var wasDragging = dragging;
3489				resetDrag();
3490				// An abort expresses no destination, so the track returns — the
3491				// same treatment onTouchCancel gives an interrupted gesture.
3492				if (wasDragging) settleBack();
3493			}
3494			return;
3495		}
3496		// A drag that cannot commit must not be allowed to follow the finger:
3497		// mid-transition it would fight the running snap, and inside the nav
3498		// cooldown (§3.1 / AC 4) it could only ever rubber-band back, which reads
3499		// as a broken control rather than as a rate limit. Refusing at the START
3500		// of the gesture is also what keeps the cooldown honest for drags — the
3501		// same guard navigate() applies to wheel and key input.
3502		if (transitioning || navLocked) {
3503			touchStartY = null;
3504			return;
3505		}
3506		touchStartY = e.touches[0].clientY;
3507		touchStartTime = Date.now();
3508		dragging = false;
3509		dragDeltaPx = 0;
3510		dragBaseIndex = currentIndex;
3511	}
3512
3513	// Bound NON-PASSIVELY (see buildContainer) — the same trap class as the
3514	// wheel handler. Without { passive: false } this preventDefault is a no-op
3515	// warning and the page scrolls underneath the viewer.
3516	function onTouchMove(e) {
3517		if (touchStartY === null) return;
3518		// `!== 1`, not `!length` (2026-08-27): belt-and-braces behind
3519		// onTouchStart's single-finger abort, which has already nulled touchStartY
3520		// by the time a second finger can move. Kept because the two guards fail
3521		// in different directions — a platform that delivers touchmove for a
3522		// second contact WITHOUT a preceding touchstart would slip past the other
3523		// one, and following finger 0 while two are down produces the same lurch.
3524		if (!e.touches || e.touches.length !== 1) return;
3525		// Finger delta, DOWN-POSITIVE. The track moves with the finger, so this
3526		// is also the track's delta — no inversion anywhere on this path.
3527		var fingerDy = e.touches[0].clientY - touchStartY;
3528		if (!dragging) {
3529			if (Math.abs(fingerDy) < DRAG_START_PX) return; // still a tap, not a drag
3530			dragging = true;
3531			dismissCoach();
3532			// The follow must be immediate; any inherited transition would make
3533			// the track lag the finger.
3534			$track.css('transition', 'none');
3535		}
3536		// Only ever called for a real drag, never for a tap — see DRAG_START_PX.
3537		e.preventDefault();
3538		dragDeltaPx = clampDragDelta(fingerDy);
3539		setTrackOffset(-dragBaseIndex * 100, dragDeltaPx);
3540		// The rail is outside the track now, so its travel is explicit rather
3541		// than inherited. Same value, never a second derivation — see
3542		// setRailTravel(). Never animated here: a transition would make the
3543		// rail lag the finger.
3544		setRailTravel(dragDeltaPx, false);
3545	}
3546
3547	function onTouchEnd(e) {
3548		if (touchStartY === null) return;
3549		var endY = (e.changedTouches && e.changedTouches[0]) ? e.changedTouches[0].clientY : touchStartY;
3550		// Legacy sign, UP-POSITIVE — kept as-is because the flick semantics below
3551		// are the ones that shipped. Not the same sign as dragDeltaPx.
3552		var dy = touchStartY - endY;
3553		var dt = Date.now() - touchStartTime;
3554		var wasDragging = dragging;
3555		var trackDelta = dragDeltaPx;
3556		resetDrag();
3557
3558		// The flick accelerator: the pre-SMASH-1978 rule, unchanged — a short,
3559		// fast swipe past 60px advances. It is a VELOCITY path (distance within a
3560		// time budget), not the "fixed distance threshold" the ticket rules out
3561		// for the snap decision, and it is retained because every native player
3562		// commits on a quick flick that never travels half the screen. Composed
3563		// with the area rule below as an OR, so the area rule alone decides slow
3564		// drags and the flick alone decides fast short ones.
3565		var isFlick = dt <= SWIPE_MAX_DURATION_MS && Math.abs(dy) >= SWIPE_THRESHOLD_PX;
3566
3567		if (!wasDragging) {
3568			// No follow ever started (a tap, or a sub-deadzone nudge). Behave
3569			// exactly as this handler did before the drag existed.
3570			if (!isFlick) return;
3571			dismissCoach();
3572			navigate(dy > 0 ? 1 : -1);
3573			return;
3574		}
3575
3576		// A follow-drag happened. Decide by AREA: the incoming slide has the
3577		// majority of the viewport once the track has moved more than half a
3578		// slide. `trackDelta` is already end-clamped, so a drag at either end is
3579		// zero here and correctly falls through to settleBack().
3580		// ── Pull-to-close (rule 1.16) ───────────────────────────────────────
3581		// Checked BEFORE the snap decision, because the two are mutually
3582		// exclusive and this one is the more specific: it fires only from slide
3583		// 0, only on a downward gesture, and only past its own threshold —
3584		// exactly the conditions under which the snap has nowhere to go.
3585		//
3586		// Measured against the FINGER's travel (`dy`), not the track's damped
3587		// follow: the threshold is a statement about the gesture the visitor
3588		// made, and clampDragDelta() deliberately moves the track less than the
3589		// finger at this end. Using the track's delta would make the real
3590		// threshold 140 / 0.35 = 400px of finger travel.
3591		//
3592		// `dy` is start-minus-end, so a DOWNWARD drag is negative — hence the
3593		// negation. The reference's measured behaviour is 130 no / 145 yes,
3594		// hence a strict greater-than.
3595		if (pullToCloseArmed(-dy)) {
3596			emitAnalytics('swipeview_pull_close', { index: currentIndex });
3597			close();
3598			return;
3599		}
3600		var h = slideHeightPx();
3601		var moved = Math.abs(trackDelta);
3602		var areaSaysAdvance = h > 0 && moved > h * DRAG_SNAP_AREA_FRACTION;
3603
3604		if (moved > 0 && (areaSaysAdvance || isFlick)) {
3605			// Negative track delta = dragged UP = next slide. Routed through
3606			// navigate() rather than goToIndex() so a drag-commit is a first-class
3607			// navigation: same cooldown, same stale-pill reset, same analytics,
3608			// and the same slide-change side effects (chrome, media activation,
3609			// caption collapse) — identical by construction rather than by
3610			// duplicated bookkeeping.
3611			navigate(trackDelta < 0 ? 1 : -1);
3612		} else {
3613			settleBack();
3614		}
3615	}
3616
3617	// An interrupted gesture (system gesture, call, extra finger) must not leave
3618	// the track parked mid-drag.
3619	// Rule 1.16's conditions, in one predicate so a test can exercise the RULE
3620	// rather than the handler: downward travel past the threshold, from the FIRST
3621	// slide, with the track at rest. The reference's third condition is
3622	// "scrollTop === 0", which is its native scroller's way of saying the same
3623	// thing our `!transitioning` does.
3624	function pullToCloseArmed(downwardPx) {
3625		if (currentIndex !== 0) return false;
3626		if (transitioning) return false;
3627		return downwardPx > PULL_CLOSE_PX;
3628	}
3629
3630	function onTouchCancel() {
3631		if (touchStartY === null) return;
3632		var wasDragging = dragging;
3633		resetDrag();
3634		if (wasDragging) settleBack();
3635	}
3636
3637	function onKeydown(e) {
3638		if (!$container || !$container.hasClass('sbi-qs-active')) return;
3639		switch (e.key) {
3640			case 'Escape':
3641				e.preventDefault(); close(); break;
3642			// Rule 1.17: the existing map plus `j`/`k`, a strict superset. The
3643			// reference binds j/k and NOT PageUp/PageDown; §3.1 requires
3644			// PageUp/PageDown; both are cheap, so both ship. `j`/`k` is the
3645			// vi/Gmail/Reddit idiom for next/previous, and it is case-insensitive
3646			// for the same reason `m` is — Shift or caps lock must not silently
3647			// disable a shortcut.
3648			case 'ArrowDown':
3649			case 'PageDown':
3650			case 'j':
3651			case 'J':
3652				e.preventDefault(); dismissCoach(); navigate(1); break;
3653			case 'ArrowUp':
3654			case 'PageUp':
3655			case 'k':
3656			case 'K':
3657				e.preventDefault(); dismissCoach(); navigate(-1); break;
3658			case ' ':
3659				e.preventDefault(); togglePlay(); break;
3660			case 'm':
3661			case 'M':
3662				toggleMute(); break;
3663			case 'Tab':
3664				trapFocus(e); break;
3665		}
3666	}
3667
3668	// §7.1 focus trap: the chrome PLUS the active slide's own focusable content
3669	// (its "View on Instagram" link — the terminal tier's only action) — never
3670	// the whole track, which would leak focus into off-screen slides.
3671	//
3672	// Rex review (SMASH-1851): .sbi-qs-unmute-pill is a real <button>, so it
3673	// IS in the document's native tab order on its own — but trapFocus()
3674	// below intercepts every Tab keypress and cycles ONLY through this
3675	// list, so a control left out of it is unreachable by keyboard even
3676	// though it's a genuine focusable element. Listing it here (rather than
3677	// giving it a bespoke visibility check) is sufficient on its own: the
3678	// offsetParent filter below already drops anything not actually
3679	// rendered, and the pill is `display: none` except when armed+muted
3680	// (see updateMuteChrome()) — so it silently drops out of the cycle
3681	// whenever it isn't visible, and rejoins the moment it is.
3682	// `.sbi-qs-actions button:not([disabled])` is GONE as of 2026-08-27: the
3683	// action stack held only the play/pause button by then, and removing that
3684	// button removed the container with it. Every surviving control is now named
3685	// individually, which is the shape this list should have had all along — a
3686	// group selector silently stops reaching a control that gets re-parented, and
3687	// that is precisely how the mute button became keyboard-unreachable for one
3688	// commit (invisible in testing, because it kept working under a mouse).
3689	//
3690	// There is deliberately NO focusable play/pause control left. The action
3691	// remains on Space (see onKeydown, which is bound at document level and so
3692	// fires regardless of where focus sits) and the STATE is announced through the
3693	// .sbi-qs-playstate live region. A screen-reader-only BUTTON was considered
3694	// and rejected: it would be an invisible Tab stop for sighted keyboard users,
3695	// trading one a11y defect for another, and it would diverge from SMASH-1853's
3696	// resolution of the identical problem.
3697	// The chevrons are named individually too, for the same reason and with the
3698	// same :not([disabled]) filter — they are disabled at the ends of the track
3699	// (updateChrome), and on a touch viewport they are `display: none`, which the
3700	// offsetParent filter below drops from the cycle without needing a second
3701	// capability check here.
3702	function focusables() {
3703		// Session-level controls, in DOM order. The unmute PILL is gone (rule 1.6
3704		// folds its job into the mute button's own expanding label, which is not
3705		// a separate focus stop — the label is aria-hidden and the button it
3706		// lives in was always in this list).
3707		var items = $container.find('.sbi-qs-mute:not([disabled]), .sbi-qs-view-on-ig:not([aria-disabled="true"]), .sbi-qs-nav-prev:not([disabled]), .sbi-qs-nav-next:not([disabled]), .sbi-qs-close').toArray();
3708		var $active = $container.find('.sbi-qs-post-active');
3709		if ($active.length) {
3710			items = items.concat($active.find('a[href], button:not([disabled])').toArray());
3711		}
3712		// ── The rail, appended LAST and deliberately as a separate step ──────
3713		// The rail's links became session-level in SMASH-1851, so they are no
3714		// longer inside `$active` and would otherwise drop out of the trap
3715		// entirely — a control that is visible, clickable and unreachable by
3716		// Tab.
3717		//
3718		// They are collected SEPARATELY rather than folded into the session
3719		// selector above, and the ordering is the reason. That selector is one
3720		// jQuery `.find()`, which returns DOCUMENT order, not selector order —
3721		// so adding the rail there would splice its items in wherever the
3722		// column happens to sit in the DOM. Concatenating here instead keeps
3723		// the Tab cycle byte-identical to what shipped: mute -> CTA pill ->
3724		// chevrons -> close -> the slide's own author / Follow / more -> the
3725		// rail's comment and View. Same sequence as when the rail was the last
3726		// thing inside the active article.
3727		//
3728		// A hidden item (no figure, no permalink) contributes nothing: `hidden`
3729		// gives it no offsetParent, and the filter below drops it — the same
3730		// mechanism that already keeps the touch-only "View" item out of the
3731		// desktop cycle.
3732		items = items.concat($container.find('.sbi-qs-railcol a[href]').toArray());
3733		return items.filter(function (node) { return node.offsetParent !== null || node === document.activeElement; });
3734	}
3735
3736	function trapFocus(e) {
3737		var items = focusables();
3738		if (!items.length) return;
3739		var i = items.indexOf(document.activeElement);
3740		e.preventDefault();
3741		var next = e.shiftKey
3742			? (i <= 0 ? items.length - 1 : i - 1)
3743			: (i === items.length - 1 || i === -1 ? 0 : i + 1);
3744		items[next].focus();
3745	}
3746
3747	// ── Open / close ────────────────────────────────────────────────────────
3748
3749	// `opener` is passed in explicitly (§7.1) — the anchor that was actually
3750	// clicked — rather than inferred from document.activeElement, which Safari
3751	// in particular does not move focus to on a link click.
3752	function open(feedPosts, startIndex, opener, embedSuppressed) {
3753		if ($container && $container.hasClass('sbi-qs-active')) return;
3754		posts = feedPosts;
3755		if (!posts.length) return;
3756
3757		suppressEmbedTier = !!embedSuppressed;
3758		slidesViewed = new Set();
3759
3760		buildContainer();
3761		renderPosts();
3762
3763		transitioning = false;
3764		navLocked = false;
3765		// A gesture interrupted by the viewer closing (or a session that ended
3766		// mid-drag) must not leave drag state armed for the next open().
3767		resetDrag();
3768		// resetDrag() sets `suppressTap` from whatever `dragging` was, so clear it
3769		// explicitly here: a session that ended mid-drag would otherwise open the
3770		// NEXT one primed to swallow its first genuine tap.
3771		suppressTap = false;
3772		clearFlash();
3773		stopSoundHint();
3774		resetWheel();
3775		lastPlayStateText = null; // Fresh session — re-announce the first real state.
3776		// Fresh session — the first write must LAND rather than ease in from
3777		// wherever the last session's bar happened to stop. The backward-move test
3778		// in setProgress() would usually catch this on its own; stating it here
3779		// makes "a session starts from nothing" an invariant rather than a
3780		// consequence of the previous session's final position.
3781		lastProgressFraction = null;
3782		// §4.2 revision (SMASH-1851, mirrors SMASH-1853 "sound-on-open"):
3783		// open() is itself a click — a qualifying gesture — so attempt
3784		// UNMUTED by default. The only writer of `true` from here on is an
3785		// explicit mute action (toggleMute / the mute button).
3786		userMuted = false;
3787		// Per-session, not per-slide: the point of the flag is that ONE proven
3788		// unmute settles the question for the rest of this viewing. navigate()
3789		// deliberately does NOT clear it —
3790		// that is what "never re-shows" means. A fresh open() is a fresh session,
3791		// and the browser's audio decision may genuinely have changed by then.
3792		unmuteSucceeded = false;
3793		// Rule 1.7: a fresh session has not been refused sound yet, so the label
3794		// starts unarmed even if a previous session on this page was refused —
3795		// the browser's policy can change between opens (a visitor who has now
3796		// interacted with the page gets a different answer).
3797		autoplayRefused = MUTED_FIRST;
3798		openerEl = opener || null;
3799
3800		$container.attr('data-muted', 'true');
3801		$container.attr('data-paused', 'false');
3802		$body.addClass('sbi-qs-scroll-locked');
3803		$container.addClass('sbi-qs-active');
3804
3805		currentIndex = Math.max(0, Math.min(startIndex || 0, posts.length - 1));
3806		slidesViewed.add(currentIndex);
3807		goToIndex(currentIndex, { smooth: false });
3808
3809		pollTimer = setInterval(tick, CHROME_POLL_MS);
3810		// Rule 1.9's first-run coach. After goToIndex(), so the frame the overlay
3811		// covers is already laid out.
3812		maybeShowCoach();
3813
3814		requestAnimationFrame(function () {
3815			// Rule 1.13's 180ms fade-in. The class has to land in a SEPARATE
3816			// frame from `.sbi-qs-active`: a transition needs a rendered starting
3817			// value, and an element that was `display: none` in the same frame
3818			// has none, so setting both together snaps to full opacity with no
3819			// fade. The focus move rides this frame for the same reason — the
3820			// element has to be rendered to be focusable.
3821			$container.addClass('sbi-qs-visible');
3822			var closeBtn = $container.find('.sbi-qs-close').get(0);
3823			if (closeBtn) closeBtn.focus();
3824		});
3825
3826		emitAnalytics('swipeview_opened', { index: currentIndex, total: posts.length });
3827	}
3828
3829	function close() {
3830		if (!$container || !$container.length || !$container.hasClass('sbi-qs-active')) return;
3831		clearInterval(pollTimer);
3832		pollTimer = null;
3833		slides.forEach(function (slide) {
3834			clearWatchdog(slide);
3835			if (slide.inst && slide.inst.destroy) slide.inst.destroy();
3836			// §4.4 (SMASH-1979): close() is the outer bound of the warm lifecycle.
3837			// destroy() above already releases the buffers for warm and activated
3838			// elements alike — this drops the now-dead references so nothing can read
3839			// a destroyed instance, and returns every slide to "not instantiated" so a
3840			// re-open re-evaluates each chain from tier 1.
3841			//
3842			// Belt-and-braces rather than load-bearing today: open() calls
3843			// renderPosts(), which rebuilds `slides` wholesale, so no flag survives a
3844			// session boundary by this route either. Written anyway because the
3845			// alternative is a teardown whose correctness depends on a caller two
3846			// functions away rebuilding the array — and `warm` in particular gates
3847			// emission isolation, which is not a property to leave resting on that.
3848			slide.inst = null;
3849			slide.tier = -1;
3850			slide.warm = false;
3851		});
3852		$container.removeClass('sbi-qs-active sbi-qs-visible');
3853		$body.removeClass('sbi-qs-scroll-locked');
3854
3855		// SMASH-1978: the gesture layer's touch listeners stay bound — close()
3856		// DEACTIVATES the container, it never tears it down — so a finger still on
3857		// the screen at close time can still deliver a trailing touchend here.
3858		// Leaving drag state armed means onTouchEnd's `touchStartY === null` bail
3859		// does not fire, and the release runs its full commit path: navigate() on a
3860		// viewer that is already closed, emitting a swipeview_next AFTER this
3861		// function's own swipeview_closed. navigate() has no active-state guard of
3862		// its own (deliberately — every other caller is reached only while open),
3863		// so the reset has to happen here.
3864		//
3865		// The realistic trigger is two fingers: one mid-drag, a second tapping
3866		// close. open() already resets for the NEXT session, but that only covers
3867		// state leaking ACROSS sessions — this closes the window inside the current
3868		// one, where the stale event is still in flight.
3869		//
3870		// State only, no settleBack(): the track's rest position is re-established
3871		// by open()'s goToIndex(currentIndex, { smooth: false }), and animating a
3872		// container that was just deactivated would be work nobody can see.
3873		resetDrag();
3874		// Every one of these timers OUTLIVES the container — close() deactivates,
3875		// it never tears the DOM down — so anything still pending would fire its
3876		// callback into a closed viewer and, worse, leave visible state parked
3877		// for the next open() to inherit: a half-dissolved pause glyph, a toast,
3878		// an expanded mute label, or a coach overlay.
3879		clearFlash();
3880		stopSoundHint();
3881		dismissCoach();
3882		resetWheel();
3883
3884		emitAnalytics('swipeview_closed', { index: currentIndex, slides_viewed: slidesViewed ? slidesViewed.size : 0 });
3885
3886		if (openerEl && typeof openerEl.focus === 'function') openerEl.focus();
3887		openerEl = null;
3888	}
3889
3890	// §6.1: play/pause + progress chrome refreshed at least every 250ms.
3891	function tick() {
3892		var slide = slides[currentIndex];
3893		// A slide with no adapter has no position to report. JUMP rather than
3894		// interpolate: easing a bar back to zero would animate a rewind that never
3895		// happened.
3896		if (!slide || !slide.inst) { setProgress(0, true); return; }
3897
3898		if (slide.inst.progress) {
3899			var p = slide.inst.progress();
3900			var frac = (p && p.duration > 0) ? (p.current / p.duration) : 0;
3901			setProgress(frac);
3902			$progressBar.attr('aria-valuenow', String(Math.round(frac * 100)));
3903		} else {
3904			setProgress(0, true);
3905		}
3906
3907		// Defensive backstop alongside the volumechange listener — see
3908		// updateMuteChrome()'s docblock.
3909		updateMuteChrome();
3910
3911		// §6.1's 250ms cadence, now feeding the live region rather than the
3912		// removed button. announcePlayState() dedupes, so this poll does not
3913		// re-announce an unchanged state four times a second.
3914		syncPlayState();
3915
3916		// The RESIZE path for the one thing that is measured rather than derived
3917		// in CSS: rule IG-5's "more" reveal, which depends on whether the
3918		// one-line caption overflows at the CURRENT width. This file binds no
3919		// resize listener at all — everything else it positions is
3920		// viewport-relative CSS that reflows on its own — so riding the existing
3921		// poll avoids a listener plus a debounce for a value that is cheap to
3922		// re-measure and only changes when the window does. It also backstops the
3923		// poster tier, whose <img> decode this file has no event bound to.
3924		syncCaptionExpansion(currentIndex);
3925	}
3926
3927	// ── Bootstrap ───────────────────────────────────────────────────────────
3928	$(function () {
3929		$body = $('body');
3930		var enabled = (window.sbiQuickScroll && window.sbiQuickScroll.enabled) || $body.hasClass('sbi-qs-enabled');
3931		if (!enabled) return;
3932		log('active — intercepting Reels clicks');
3933
3934		document.addEventListener('keydown', onKeydown, true);
3935
3936		// Capture-phase so this fires before the Lightbox2 bubble handler AND
3937		// before the shoppable/moderation code that also owns tile clicks.
3938		document.addEventListener('click', function (e) {
3939			var a = e.target && e.target.closest ? e.target.closest('a.sbi_link_area[data-lightbox-sbi]') : null;
3940			if (!a) return;
3941			var $a = $(a);
3942			if (!isReelAnchor($a)) return;
3943			var $feed = $a.closest('.sbi');
3944			if (!$feed.length) return;
3945			if (isDispatchSuppressed($a, $feed)) return; // §2.2: fall through to the existing lightbox untouched.
3946
3947			var reels = collectReelsFromFeed($feed);
3948			if (!reels.length) return; // No eligible Reels survived the guards — let the lightbox handle it.
3949
3950			var clickedId = $a.attr('data-id');
3951			var index = 0;
3952			for (var i = 0; i < reels.length; i++) {
3953				if (reels[i].id === clickedId) { index = i; break; }
3954			}
3955			// The clicked post itself may have been filtered out of `reels` by a
3956			// guard that only applies to it (shouldn't happen given the guards are
3957			// feed/anchor-level, but stay defensive rather than opening on the
3958			// wrong post).
3959			if (reels[index] && reels[index].id !== clickedId) return;
3960
3961			e.preventDefault();
3962			e.stopImmediatePropagation();
3963			log('opening viewer', clickedId, 'at', index, '/', reels.length);
3964			open(reels, index, a, feedSuppressesEmbedTier($feed));
3965		}, true);
3966	});
3967})(jQuery);

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.