(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);
});
});
}
})();