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.