(function () { "use strict"; // Native Webflow elements (2026-10-12). The page is built from Designer elements, not code, so: // links marked data-newtab open in a new tab (the HTML import drops target), the nav link Webflow // marks w--current gets aria-current for the underline, and the menu toggle (a link, since Webflow // has no plain button element) answers Enter and Space like a button. document.querySelectorAll("a[data-newtab]").forEach(function (a) { a.target = "_blank"; a.rel = "noopener"; }); document.querySelectorAll(".nav a.w--current").forEach(function (a) { if ((a.getAttribute("href") || "").indexOf("#") < 0) a.setAttribute("aria-current", "page"); }); var toggle = document.querySelector("[data-menu]"); if (toggle && toggle.tagName === "A") toggle.addEventListener("keydown", function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle.click(); } }); // Dinner goes to OpenTable. Every event, ticket and VIP table goes to Speakeasy. var LINKS = { book: "https://www.opentable.com/r/ball-and-chain-reservations-miami?restref=468352&lang=en-US&ot_source=Restaurant%20website" }; // ---- SPEAKEASY: Ball & Chain's links, read from the manager 2026-10-07 (account "Ball & Chain"). ---- // Every RSVP button goes straight into a dated Speakeasy event, never the venue page: the venue // page lists nothing and series pages say "Invalid date". // nights: each weekly night's dated events, "YYYY-MM-DD EVE-XXXXXX" (the Miami date Speakeasy shows). // The button opens the first one not yet past. A night with none, or with every date // past, falls back to the venue page. Extend these lists when the series are extended. // Little Havana Under the Stars is dated on Wednesdays in Speakeasy; the flyer says Thursday. // special: the current ticketed one-off (Caja China). Blank keeps the strip's own link. var SPEAKEASY = { venue: "https://speakeasygo.com/ball--chain?vid=VN-otz9", nights: {}, special: "" }; // Webflow: the nights, their days and their Speakeasy dates come from the Weekly Nights collection, // bound onto data- attributes. "Speakeasy Dates" holds lines of "YYYY-MM-DD EVE-XXXXXX". document.querySelectorAll("[data-night][data-dates]").forEach(function (el) { var found = (el.getAttribute("data-dates") || "").match(/\d{4}-\d{2}-\d{2}\s+EVE-[A-Za-z0-9]+/g) || []; if (found.length || !SPEAKEASY.nights[el.dataset.night]) SPEAKEASY.nights[el.dataset.night] = found.map(function (s) { return s.replace(/\s+/, " "); }); }); var DAYS = { "every day": "daily", sundays: "0", mondays: "1", tuesdays: "2", wednesdays: "3", thursdays: "4", fridays: "5", saturdays: "6" }; function hour24(t) { var m = /^(\d{1,2})(?::\d{2})?\s*(AM|PM)$/i.exec(t.trim()); if (!m) return null; var h = parseInt(m[1], 10) % 12; return /pm/i.test(m[2]) ? h + 12 : h; } document.querySelectorAll(".night[data-dayname], .ev-row[data-dayname]").forEach(function (n) { var d = DAYS[(n.dataset.dayname || "").toLowerCase()]; if (d) n.dataset.day = d; var t = n.dataset.time || "", parts = t.split("-"); var closes = /close/i.test(t), a = hour24(parts[0] || ""), b = closes ? null : hour24(parts[1] || ""); if (a !== null && b !== null) n.dataset.window = a + "-" + (b <= a ? b + 24 : b); if (n.classList.contains("night")) { var dayEl = n.querySelector(".day"), info = n.querySelector(".info"), pill = n.querySelector(".pill"); if (dayEl && n.dataset.window && t) dayEl.textContent = dayEl.textContent + ", " + t; if (info && closes) info.textContent = info.textContent.replace(/\s*$/, "") + " " + t + "."; if (pill && d === "daily") pill.textContent = "Now playing"; } // A night whose button is Reserve Lunch & Dinner books SevenRooms, not Speakeasy. if (/^Reserve (Lunch & )?Dinner$/.test(n.dataset.button || "")) { var ov = n.querySelector(".night-overlay"); if (ov) ov.remove(); n.querySelectorAll("[data-se-buy]").forEach(function (a) { a.removeAttribute("data-se-buy"); a.setAttribute("data-book", ""); a.textContent = "Reserve Lunch & Dinner"; a.classList.remove("btn-fill"); a.classList.add("btn-line"); }); n.querySelectorAll("[data-se-tables]").forEach(function (a) { a.remove(); }); } }); var seVenue = SPEAKEASY.venue || "/events"; // Today in Miami as YYYY-MM-DD. Before 4AM still counts as the night before. function miamiDate() { var d = new Date(Date.now() - 4 * 3600 * 1000); return new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York", year: "numeric", month: "2-digit", day: "2-digit" }).format(d); } var today = miamiDate(); function nightUrl(slug) { var list = SPEAKEASY.nights[slug] || []; for (var i = 0; i < list.length; i++) { var p = list[i].split(" "); if (p[0] >= today) return "https://speakeasygo.com/event/" + p[1]; } return seVenue; } // ?modal=tables opens Speakeasy's table picker straight away. Only an event page has one. function tablesUrl(u) { return /speakeasygo\.com\/event\//.test(u) ? u + (u.indexOf("?") > -1 ? "&" : "?") + "modal=tables" : u; } function setHref(a, url) { a.href = url; if (/^https?:/.test(url)) { a.target = "_blank"; a.rel = "noopener"; } else { a.removeAttribute("target"); } } document.querySelectorAll("[data-book]").forEach(function (a) { a.href = LINKS.book; if (/^https?:/.test(LINKS.book)) { a.target = "_blank"; a.rel = "noopener"; } }); // General RSVP and VIP buttons are not tied to one night: they open our events list, where every // night's button goes straight into its Speakeasy event. document.querySelectorAll("[data-tickets], [data-se-tables-general]").forEach(function (a) { setHref(a, location.pathname.indexOf("/events") === 0 ? "#weekly-title" : "/events"); }); document.querySelectorAll("[data-se-buy]").forEach(function (a) { setHref(a, nightUrl(a.dataset.night)); }); document.querySelectorAll("[data-se-tables]").forEach(function (a) { setHref(a, tablesUrl(nightUrl(a.dataset.night))); }); if (SPEAKEASY.special) document.querySelectorAll("[data-se-special]").forEach(function (a) { setHref(a, SPEAKEASY.special); }); var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; // Header: solid once the hero scrolls away, and the phone menu. var header = document.querySelector("[data-header]"); var menuBtn = document.querySelector("[data-menu]"); function onScroll() { header.classList.toggle("is-solid", window.scrollY > 40); } onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); if (menuBtn) { menuBtn.addEventListener("click", function () { var open = header.classList.toggle("is-open"); menuBtn.setAttribute("aria-expanded", String(open)); document.body.style.overflow = open ? "hidden" : ""; }); header.querySelectorAll(".nav a").forEach(function (a) { a.addEventListener("click", function () { header.classList.remove("is-open"); menuBtn.setAttribute("aria-expanded", "false"); document.body.style.overflow = ""; }); }); } // Tonight, in Miami time. Before 4AM still counts as the night before. function miamiNow() { var parts = new Intl.DateTimeFormat("en-US", { timeZone: "America/New_York", weekday: "short", hour: "numeric", hour12: false }).formatToParts(new Date()); var wd = parts.find(function (p) { return p.type === "weekday"; }).value; var hr = parseInt(parts.find(function (p) { return p.type === "hour"; }).value, 10) % 24; var day = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(wd); if (hr < 4) { day = (day + 6) % 7; hr += 24; } return { day: day, hour: hr }; } var now = miamiNow(); var nights = Array.prototype.slice.call(document.querySelectorAll(".night[data-day]")); var tonight = null; nights.forEach(function (n) { var d = n.dataset.day; if (d !== "daily" && parseInt(d, 10) !== now.day) return; var w = n.dataset.window; if (w) { var span = w.split("-").map(Number); if (d === "daily" && now.hour < span[0]) return; if (now.hour >= span[1]) return; // the afternoon set is over, the night takes over } if (!tonight) { tonight = n; n.classList.add("is-tonight"); } }); var tBox = document.querySelector("[data-tonight]"); if (tonight && tBox) { tBox.querySelector(".tag").textContent = tonight.dataset.day === "daily" ? "Now playing" : "Tonight"; tBox.querySelector("[data-tonight-name]").textContent = tonight.querySelector(".name").textContent; tBox.querySelector("[data-tonight-detail]").textContent = tonight.querySelector(".info").textContent; tBox.hidden = false; } // Start the week reel on tonight's card. if (tonight) { var group = tonight.parentNode; while (group.firstElementChild !== tonight) group.appendChild(group.firstElementChild); } // Ticketed strip: gone the day after its event. var miamiDate = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York" }).format(new Date()); document.querySelectorAll("[data-until]").forEach(function (el) { var u = el.dataset.until || ""; if (u && !/^\d{4}-\d{2}-\d{2}$/.test(u)) { var p = new Date(u); u = isNaN(p) ? "" : new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York" }).format(p); } if (u && miamiDate > u) el.remove(); }); // Reels: duplicate the group once, set duration from its width (px per second). document.querySelectorAll("[data-reel]").forEach(function (reel) { var track = reel.querySelector(".reel-track"); var group = track.querySelector(".reel-group"); var copy = group.cloneNode(true); copy.setAttribute("aria-hidden", "true"); copy.querySelectorAll("a").forEach(function (a) { a.tabIndex = -1; }); copy.querySelectorAll("img").forEach(function (i) { i.alt = ""; }); track.appendChild(copy); var speed = parseFloat(reel.dataset.speed || "80"); function size() { track.style.setProperty("--dur", Math.max(group.scrollWidth / speed, 20) + "s"); } size(); if ("ResizeObserver" in window) new ResizeObserver(size).observe(group); }); // Scroll reveals. var reveals = document.querySelectorAll(".reveal"); if (reduce || !("IntersectionObserver" in window)) { reveals.forEach(function (el) { el.classList.add("is-in"); }); } else { var io = new IntersectionObserver(function (entries) { entries.forEach(function (e) { if (e.isIntersecting) { e.target.classList.add("is-in"); io.unobserve(e.target); } }); }, { rootMargin: "0px 0px -8% 0px" }); reveals.forEach(function (el) { io.observe(el); }); } // Hero video: still frame for reduced motion, pause when off screen. var vid = document.querySelector("[data-hero-video]"); if (vid) { if (reduce) { vid.removeAttribute("autoplay"); vid.pause(); } else if ("IntersectionObserver" in window) { new IntersectionObserver(function (es) { es.forEach(function (e) { if (e.isIntersecting) { var p = vid.play(); if (p && p.catch) p.catch(function () {}); } else vid.pause(); }); }).observe(vid); } } // ---------- Characters ---------- // WebKit (desktop Safari and every iOS browser) reports it can play VP9 WebM and then // drops the alpha channel, so the format is picked by engine, never by canPlayType. var ua = navigator.userAgent; var isWebKit = /iPhone|iPad|iPod/.test(ua) || (/AppleWebKit/.test(ua) && !/Chrome|Chromium|Edg|OPR|Firefox/.test(ua)); function makeChar(name, cls) { var CH = {"trovador.webm": "https://cdn.prod.website-files.com/6accf3d7da456d0f21f13a93/6adb7573088f9a17aeeae2eb_trovador-v2.webm", "dancer.png": "https://cdn.prod.website-files.com/6accf3d7da456d0f21f13a93/6adb7572b516589192cdaef5_dancer-v2.png", "dancer.webm": "https://cdn.prod.website-files.com/6accf3d7da456d0f21f13a93/6adb75723847f8b88afb78b0_dancer-v2.webm", "dancer.webp": "https://cdn.prod.website-files.com/6accf3d7da456d0f21f13a93/6adb757277aa208dadcc100c_dancer-v2.webp", "trovador.png": "https://cdn.prod.website-files.com/6accf3d7da456d0f21f13a93/6adb7572e98edbe444908d17_trovador-v2.png", "trovador.webp": "https://cdn.prod.website-files.com/6accf3d7da456d0f21f13a93/6adb7573fccb158ba6d04577_trovador-v2.webp"}, el; function src(ext) { return CH[name + "." + ext]; } if (reduce) { el = document.createElement("img"); el.src = src("png"); } else if (isWebKit) { el = document.createElement("img"); el.src = src("webp"); } else { el = document.createElement("video"); el.muted = true; el.loop = true; el.autoplay = true; el.playsInline = true; el.setAttribute("muted", ""); el.setAttribute("playsinline", ""); el.poster = src("png"); el.src = src("webm"); } el.className = cls; el.alt = ""; el.setAttribute("aria-hidden", "true"); return el; } // El Trovador sits on the end of the hero's Book a Table. document.querySelectorAll("[data-char=trovador]").forEach(function (slot) { slot.replaceWith(makeChar("trovador", "char trov")); }); // The dancer travels between [data-perch] buttons as the page scrolls. Homepage only. var first = document.querySelector("[data-perch-first]"); if (first) { if (reduce) { first.appendChild(makeChar("dancer", "char dance-static")); } else { initDancer(); } } function initDancer() { var el = makeChar("dancer", "dancer-layer"); document.body.appendChild(el); var phone = window.matchMedia("(max-width: 1080px)"); var ratio = 168 / 200; var cur = null, jump = null, pos = { x: -999, y: -999 }, hoverTimer = null; function size() { var h = el.getBoundingClientRect().height || (phone.matches ? 68 : 92); return { h: h, w: h * ratio }; } function targets() { return Array.prototype.slice.call(document.querySelectorAll(phone.matches ? ".action-bar .btn" : "[data-perch]")); } function spot(t) { var r = t.getBoundingClientRect(), s = size(), x = r.left + r.width / 2 - s.w / 2; if (phone.matches) { // Phone: stand at the outer end of each bar button, keeping the middle of the screen clear. var bar = targets(); x = t === bar[0] ? r.left + 10 : r.right - s.w - 10; } // Phone: she stands on the bar itself. Its dark background starts above the buttons and // sits in front of her, so standing on a button's top edge hid her feet and skirt. var floor = r.top; if (phone.matches) { var barEl = document.querySelector(".action-bar"); if (barEl) floor = barEl.getBoundingClientRect().top; } return { x: x, y: floor - s.h * 0.98 + 1, r: r }; } function usable(t) { var r = t.getBoundingClientRect(), s = size(); if (!r.width) return false; if (phone.matches) return true; var head = header ? header.getBoundingClientRect().bottom : 0; return r.top - s.h > head + 4 && r.bottom < innerHeight - 8; } function pick(exclude) { var mid = innerHeight * 0.55, best = null, bd = Infinity; targets().forEach(function (t) { if (t === exclude || !usable(t)) return; var d = Math.abs(t.getBoundingClientRect().top - mid); if (d < bd) { bd = d; best = t; } }); return best; } function hopTo(t) { if (!t || t === cur) return; var from = { x: pos.x, y: pos.y }; if (from.x < -900) { var s0 = spot(t); from = { x: s0.x, y: s0.y + 40 }; } // first entrance: a small hop up jump = { t: t, from: from, start: performance.now() }; jump.dur = Math.min(900, 520 + Math.hypot(spot(t).x - from.x, spot(t).y - from.y) * 0.35); cur = t; el.classList.add("is-on"); } function ease(p) { return p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2; } function apply(x, y, sx, sy, lean) { pos.x = x; pos.y = y; el.style.transform = "translate(" + x.toFixed(1) + "px," + y.toFixed(1) + "px) rotate(" + lean.toFixed(2) + "deg) scale(" + sx.toFixed(3) + "," + sy.toFixed(3) + ")"; } function frame(now) { if (jump) { var p = Math.min((now - jump.start) / jump.dur, 1), to = spot(jump.t), e = ease(p); var dx = to.x - jump.from.x, dy = to.y - jump.from.y; var arc = Math.min(170, 70 + Math.hypot(dx, dy) * 0.22); var sx = 0.96, sy = 1.06; // stretched in the air if (p < 0.14) { var k = Math.sin(p / 0.14 * Math.PI); sy = 1 - 0.2 * k; sx = 1 + 0.12 * k; } // crouch to take off else if (p > 0.84) { var q = Math.sin((p - 0.84) / 0.16 * Math.PI); sy = 1 - 0.18 * q; sx = 1 + 0.1 * q; } // land apply(jump.from.x + dx * e, jump.from.y + dy * e - arc * 4 * p * (1 - p), sx, sy, (dx > 0 ? 1 : -1) * 9 * Math.sin(p * Math.PI)); if (p >= 1) jump = null; } else if (cur) { var s = spot(cur); apply(s.x, s.y, 1, 1, 0); // ride the button as it scrolls if (!usable(cur)) { var n = pick(cur); if (n) hopTo(n); else el.classList.remove("is-on"); } else el.classList.add("is-on"); } else { var f = phone.matches ? pick() : (usable(first) ? first : pick()); if (f) hopTo(f); } requestAnimationFrame(frame); } requestAnimationFrame(frame); // Desktop: hovering a visible landing spot calls her over. targets().forEach(function (t) { t.addEventListener("mouseenter", function () { if (phone.matches || !usable(t)) return; clearTimeout(hoverTimer); hoverTimer = setTimeout(function () { hopTo(t); }, 140); }); t.addEventListener("mouseleave", function () { clearTimeout(hoverTimer); }); }); // Phone: she lives on the bottom bar and hops to the other button at each new section. if ("IntersectionObserver" in window) { var seen = new IntersectionObserver(function (es) { es.forEach(function (e) { if (!e.isIntersecting || !phone.matches || jump) return; var bar = targets(); if (bar.length < 2) return; hopTo(cur === bar[0] ? bar[1] : bar[0]); }); }, { threshold: 0.45 }); document.querySelectorAll("main section").forEach(function (sec) { seen.observe(sec); }); } phone.addEventListener && phone.addEventListener("change", function () { cur = null; jump = null; pos = { x: -999, y: -999 }; }); } // ---------- More perches: El Trovador on letters and banners ---------- // He sits on the cap height of a real letter. The cap top is measured, not guessed: // baseline from a zero-size inline-block probe, minus the glyph's measured ascent. var capCanvas = document.createElement("canvas").getContext("2d"); function seatOnLetter(span) { var img = span.querySelector(".trov-perch"); if (!img) return; var cs = getComputedStyle(span), fs = parseFloat(cs.fontSize); var probe = span.querySelector(".baseline-probe"); if (!probe) { probe = document.createElement("i"); probe.className = "baseline-probe"; probe.style.cssText = "display:inline-block;width:0;height:0;vertical-align:baseline"; span.insertBefore(probe, img); } capCanvas.font = cs.fontWeight + " " + fs + "px " + cs.fontFamily; var letter = (span.firstChild && span.firstChild.nodeValue || "M").trim().toUpperCase() || "M"; var ascent = capCanvas.measureText(letter).actualBoundingBoxAscent; var sr = span.getBoundingClientRect(); var capTop = probe.getBoundingClientRect().top - sr.top - ascent; var h = Math.max(56, Math.min(fs * (span.dataset.scale ? parseFloat(span.dataset.scale) : 1.15), 170)); var w = h * 130 / 200; img.style.height = h + "px"; img.style.top = (capTop - h * 0.56) + "px"; img.style.left = (sr.width / 2 - w * 0.5) + "px"; } var letterPerches = []; function addLetterPerch(span) { span.appendChild(makeChar("trovador", "trov-perch")); letterPerches.push(span); } document.querySelectorAll(".perch-letter[data-trov]").forEach(addLetterPerch); document.querySelectorAll(".perch-banner[data-trov]").forEach(function (b) { b.appendChild(makeChar("trovador", "trov-perch")); }); // ---------- El Trovador plays: a few notes drift up off the guitar ---------- // Drawn as shapes, not a font glyph, so they look the same everywhere. They take the colour of // the text around him: cream on dark bands, ink on cream ones. var NOTES = [ '', '' ]; var players = []; function placeOver(box, c) { box.style.left = c.offsetLeft + "px"; box.style.top = c.offsetTop + "px"; box.style.width = c.offsetWidth + "px"; box.style.height = c.offsetHeight + "px"; } function addNotes(c) { if (reduce || !c.parentNode) return; var box = document.createElement("span"); box.className = "trov-notes"; box.setAttribute("aria-hidden", "true"); c.parentNode.appendChild(box); players.push({ c: c, box: box, live: 0, on: false, next: 0 }); } function spawn(pl) { if (pl.live >= 3 || !pl.c.offsetHeight) return; placeOver(pl.box, pl.c); pl.box.style.fontSize = Math.max(13, pl.c.offsetHeight * 0.19) + "px"; var n = document.createElement("span"); n.className = "trov-note"; n.innerHTML = NOTES[Math.random() < 0.65 ? 0 : 1]; // Near the right edge of the screen the notes drift left, so they never widen a phone's page. var nearEdge = pl.c.getBoundingClientRect().right > document.documentElement.clientWidth - 70; n.style.setProperty("--dx", ((nearEdge ? -1.4 : 0.6) + Math.random() * 1.6).toFixed(2) + "em"); n.style.setProperty("--dy", (-3.6 - Math.random() * 1.8).toFixed(2) + "em"); n.style.setProperty("--rot", (Math.random() * 40 - 12).toFixed(0) + "deg"); n.style.setProperty("--s", (0.8 + Math.random() * 0.4).toFixed(2)); pl.live++; n.addEventListener("animationend", function () { n.remove(); pl.live--; }); pl.box.appendChild(n); } function startNotes() { document.querySelectorAll(".char.trov, .trov-perch").forEach(addNotes); if (players.length && "IntersectionObserver" in window) { var noteIO = new IntersectionObserver(function (es) { es.forEach(function (e) { players.forEach(function (pl) { if (pl.c === e.target) pl.on = e.isIntersecting; }); }); }); players.forEach(function (pl) { noteIO.observe(pl.c); }); setInterval(function () { if (document.hidden) return; var t = Date.now(); players.forEach(function (pl) { if (!pl.on || t < pl.next) return; spawn(pl); pl.next = t + 900 + Math.random() * 900; }); }, 150); } } // ---------- He talks: each perch has its own line, said once per visit ---------- // The line lives on the perch as data-line, so it travels with its section. One bubble at a // time, a breath between them, and only once he has been fully on screen for a moment. function lineFor(c) { var el = c.closest("[data-line]"); return el ? el.dataset.line : ""; } var said = {}; try { said = JSON.parse(sessionStorage.getItem("bc-said") || "{}"); } catch (e) {} function chatter() { var cands = Array.prototype.slice.call(document.querySelectorAll(".char.trov, .trov-perch")); if (!cands.length || !("IntersectionObserver" in window)) return; var inView = [], talking = false, quietUntil = 0; var talkIO = new IntersectionObserver(function (es) { es.forEach(function (e) { var i = inView.findIndex(function (v) { return v.c === e.target; }); if (e.isIntersecting && e.intersectionRatio >= 0.9) { if (i < 0) inView.push({ c: e.target, since: Date.now() }); } else if (i > -1) inView.splice(i, 1); }); }, { threshold: [0, 0.9] }); cands.forEach(function (c) { talkIO.observe(c); }); setInterval(function () { if (talking || document.hidden || Date.now() < quietUntil) return; for (var k = 0; k < inView.length; k++) { var v = inView[k], line = lineFor(v.c); if (!line || said[line] || !v.c.offsetHeight || Date.now() - v.since < 700) continue; // Room for the bubble above his head, clear of the sticky header, or he waits. var headBottom = header ? header.getBoundingClientRect().bottom : 0; if (v.c.getBoundingClientRect().top < headBottom + 64) continue; said[line] = 1; try { sessionStorage.setItem("bc-said", JSON.stringify(said)); } catch (e) {} talking = true; bubble(v.c, line, function () { talking = false; quietUntil = Date.now() + 2500; }); return; } }, 300); } function bubble(c, line, done) { var b = document.createElement("span"); b.setAttribute("aria-hidden", "true"); // a cartoon aside: the page says it all in words already b.textContent = line; // Read the screen width first, then size the bubble out of the flow: placed inside the page // before it is clamped, it widens a phone's layout and the clamp then trusts the wrong width. var vw = document.documentElement.clientWidth; b.className = "trov-bubble"; b.style.cssText = "position:fixed;left:0;top:0;visibility:hidden"; document.body.appendChild(b); var bw = b.offsetWidth, bh = b.offsetHeight; b.style.cssText = ""; c.parentNode.appendChild(b); var w = c.offsetWidth; var hero = c.classList.contains("char"); // the hero: up and to his left, clear of the dancer var left = hero ? c.offsetLeft + w * 0.62 - bw : c.offsetLeft + w * 0.45 - bw / 2; var top = c.offsetTop - bh - 4; var originX = c.parentNode.getBoundingClientRect().left; var minL = 10 - originX, maxL = vw - 10 - originX - bw; left = Math.max(minL, Math.min(left, maxL)); // The tail points at his head wherever the clamp left the bubble. var tail = Math.max(14, Math.min(bw - 14, c.offsetLeft + w * 0.45 - left)); b.style.left = left + "px"; b.style.top = top + "px"; b.style.setProperty("--tail", tail + "px"); requestAnimationFrame(function () { requestAnimationFrame(function () { b.classList.add("is-on"); }); }); setTimeout(function () { b.classList.remove("is-on"); setTimeout(function () { b.remove(); if (done) done(); }, 500); }, 4200); } // ---------- Footer: the wordmark flows in and dances; the year rolls to 1935 ---------- var word = document.querySelector("[data-wordmark]"); if (word) { var text = word.textContent; word.textContent = ""; var letters = []; Array.prototype.forEach.call(text, function (ch, i) { var sp = document.createElement("span"); sp.className = "wl"; sp.textContent = ch; sp.style.setProperty("--i", i); sp.style.setProperty("--tilt", (i % 2 ? -1 : 1) * 1.2 + "deg"); word.appendChild(sp); letters.push(sp); }); // He sits on the final N. var last = letters[letters.length - 1]; last.classList.add("perch-letter"); last.dataset.scale = "0.62"; last.dataset.line = "Hasta luego! See you on Calle Ocho."; addLetterPerch(last); } function seatAll() { letterPerches.forEach(seatOnLetter); } seatAll(); if (document.fonts && document.fonts.ready) document.fonts.ready.then(seatAll); window.addEventListener("resize", seatAll); var odo = document.querySelector("[data-odo]"); if (odo) { var target = odo.dataset.odo, start = String(new Date().getFullYear()); odo.textContent = ""; var strips = []; Array.prototype.forEach.call(target, function (d, i) { var col = document.createElement("span"); col.className = "odo-digit"; var strip = document.createElement("span"); strip.className = "odo-strip"; // Roll from this year's digit, down through the numbers, to the founding digit. var from = parseInt(start[i], 10), to = parseInt(d, 10), seq = []; for (var n = 0; n <= 10 + ((from - to + 10) % 10); n++) seq.push(((from - n) % 10 + 10) % 10); seq.forEach(function (n) { var c = document.createElement("span"); c.textContent = n; strip.appendChild(c); }); col.appendChild(strip); odo.appendChild(col); strips.push({ strip: strip, steps: seq.length - 1, delay: i * 140 }); }); odo._roll = function () { strips.forEach(function (o) { o.strip.style.transitionDelay = o.delay + "ms"; o.strip.style.transform = "translateY(-" + o.steps + "em)"; }); }; if (reduce) odo._roll(); } if (word && "IntersectionObserver" in window) { var footIO = new IntersectionObserver(function (es) { es.forEach(function (e) { if (e.isIntersecting) { word.classList.add("is-in"); word.classList.remove("is-paused"); if (odo && odo._roll) odo._roll(); setTimeout(seatAll, 50); } else if (word.classList.contains("is-in")) word.classList.add("is-paused"); }); }, { threshold: 0.25 }); footIO.observe(word); } else if (word) word.classList.add("is-in"); // Characters below the fold only play while on screen. if ("IntersectionObserver" in window) { var playIO = new IntersectionObserver(function (es) { es.forEach(function (e) { var v = e.target; if (v.tagName !== "VIDEO") return; if (e.isIntersecting) { var pr = v.play(); if (pr && pr.catch) pr.catch(function () {}); } else v.pause(); }); }); document.querySelectorAll("video.trov-perch, video.char").forEach(function (v) { playIO.observe(v); }); } // Every perch exists by now, the footer's included: start the music and the talk. startNotes(); chatter(); // Signup posts to the site's form handler. If that fails, hand the address to email // so a guest who typed it in is never silently dropped. var form = document.querySelector("[data-signup]"); if (form) { var msg = form.parentNode.querySelector(".signup-msg"); form.addEventListener("submit", function (e) { e.preventDefault(); var btn = form.querySelector("button"); btn.disabled = true; var body = new URLSearchParams(new FormData(form)).toString(); function say(t) { if (msg) { msg.textContent = t; msg.hidden = false; } } fetch("/", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body }) .then(function (r) { if (!r.ok) throw new Error(r.status); form.reset(); btn.textContent = "Signed Up"; say("You're on the list. See you on Calle Ocho."); }) .catch(function () { btn.disabled = false; var em = form.querySelector("[name=email]").value, nm = form.querySelector("[name=first_name]").value; say("That didn't go through. Opening an email to us instead."); window.location.href = "mailto:info@ballandchainmiami.com?subject=" + encodeURIComponent("Add me to the list") + "&body=" + encodeURIComponent((nm ? nm + "\n" : "") + em); }); }); } })();