1/* Pass 2 â six rooms in The Arrangement, a tracking Climb, a still hero. 2 Enhancement layer. Conventions carried over from btt-band.js, each 3 learned the hard way on this ecosystem: never key off builder class 4 names (find things by their text), wait for hydration to settle, and if 5 a re-render drops the work, put it back rather than assume it stays. */ 6(function () { 7 "use strict"; 8 9 var BTT_URL = "https://abridgetothethrone.com"; 10 var COVER = "/assets/enhance/btt-cover.jpg"; 11 var SERVICE_PATH = "/work-with-dr-toby"; 12 var BEAT = 0.66; // 90 BPM 13 var STEP = 0.11; // six tiles x 0.11 = 0.55s, still one beat 14 var TILE_ID = "gt-tile-btt"; 15 16 // Verbatim from abridgetothethrone.com, except `claim`, which follows the 17 // section's own "The X, verbed." pattern. 18 var BTT = { 19 category: "Book / Method", 20 title: "A Bridge to the Throne", 21 claim: "The crossing, structured.", 22 excerpt: "Structure, alignment, and the crossing from survival to legacy.", 23 chip: "Coming Soon" 24 }; 25 26 var isHome = (function () { 27 var p = location.pathname.replace(/\/+$/, "") || "/"; 28 return p === "/" || /\/index(\.html)?$/.test(p); 29 })(); 30 31 /* The exported Framer router can update the URL without replacing the 32 server-rendered page in a static Pages deployment. Keep internal route 33 changes native so every destination receives its own complete document. */ 34 window.addEventListener("click", function (event) { 35 if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; 36 var link = event.target && event.target.closest && event.target.closest("a[href]"); 37 if (!link || link.hasAttribute("download") || (link.target && link.target !== "_self")) return; 38 var url; 39 try { url = new URL(link.href, location.href); } catch (_) { return; } 40 if (url.origin !== location.origin || !/^https?:$/.test(url.protocol)) return; 41 var sameDocument = url.pathname === location.pathname && url.search === location.search; 42 if (sameDocument && url.hash) return; 43 if (url.href === location.href) return; 44 event.preventDefault(); 45 event.stopImmediatePropagation(); 46 location.assign(url.href); 47 }, true); 48 49 function txt(el) { return (el && el.textContent || "").trim(); } 50 51 function linkPath(link) { 52 try { return new URL(link.href, location.href).pathname.replace(/\.html$/, "").replace(/\/+$/, "") || "/"; } 53 catch (_) { return ""; } 54 } 55 56 function findPathLink(root, path) { 57 var links = root ? root.querySelectorAll("a[href]") : []; 58 for (var i = 0; i < links.length; i++) if (linkPath(links[i]) === path) return links[i]; 59 return null; 60 } 61 62 /* Services is a first-class site destination. Clone the export's own 63 Speaking row so desktop, tablet, phone, and footer navigation retain 64 their native typography, spacing, breakpoints, and focus behavior. */ 65 function integrateServices() { 66 var navs = document.querySelectorAll('nav[data-framer-name="Desktop"], nav[data-framer-name="Tablet"], nav[data-framer-name="Phone"]'); 67 for (var i = 0; i < navs.length; i++) { 68 var nav = navs[i]; 69 if (findPathLink(nav, SERVICE_PATH)) continue; 70 var speaking = findPathLink(nav, "/speaking"); 71 var row = speaking && speaking.closest("[data-framer-name]"); 72 if (!row || !row.parentNode) continue; 73 var entry = row.cloneNode(true); 74 var link = entry.querySelector("a[href]"); 75 if (!link) continue; 76 entry.classList.add("gt-services-nav-entry"); 77 entry.setAttribute("data-framer-name", "Services"); 78 link.href = SERVICE_PATH; 79 link.textContent = "Services"; 80 link.removeAttribute("data-framer-page-link-current"); 81 link.removeAttribute("aria-current"); 82 row.insertAdjacentElement("afterend", entry); 83 } 84 85 var groups = document.querySelectorAll('footer [data-framer-name="Quick Links"]'); 86 for (var g = 0; g < groups.length; g++) { 87 var group = groups[g]; 88 var existingFooterLink = findPathLink(group, SERVICE_PATH); 89 if (existingFooterLink) { 90 var existingFooterRow = existingFooterLink.closest("[data-framer-name]"); 91 if (existingFooterRow) existingFooterRow.classList.add("gt-services-footer-entry", "gts-footer-entry"); 92 continue; 93 } 94 var footerSpeaking = findPathLink(group, "/speaking"); 95 var footerRow = footerSpeaking && footerSpeaking.closest("[data-framer-name]"); 96 if (!footerRow || !footerRow.parentNode) continue; 97 var footerEntry = footerRow.cloneNode(true); 98 var footerLink = footerEntry.querySelector("a[href]"); 99 if (!footerLink) continue; 100 footerEntry.classList.add("gt-services-footer-entry", "gts-footer-entry"); 101 footerEntry.setAttribute("data-framer-name", "Services"); 102 footerLink.href = SERVICE_PATH; 103 footerLink.textContent = "Services"; 104 footerLink.removeAttribute("data-framer-page-link-current"); 105 footerLink.removeAttribute("aria-current"); 106 footerRow.insertAdjacentElement("afterend", footerEntry); 107 } 108 } 109 110 /* Downward arrows are excluded from link-like controls throughout this 111 site. The export can recreate the Testimonials disclosure during 112 hydration, so enforce the rule on each bounded repair pass as well. */ 113 function stripDownArrows() { 114 var controls = document.querySelectorAll("a, button"); 115 var arrows = /[ââââ§â©â£â¼â½â¾ââ·â®â£â¤µâ¤¶â¤·]/g; 116 for (var i = 0; i < controls.length; i++) {
117 var walker = document.createTreeWalker(controls[i], NodeFilter.SHOW_TEXT); 118 var node; 119 while ((node = walker.nextNode())) node.nodeValue = node.nodeValue.replace(arrows, ""); 120 } 121 } 122 123 /* ââ Builder subdomains -> the real domains ââââââââââââââââââââââââââ 124 The base HTML and the runtime bundle are both corrected, so this is a 125 belt-and-braces pass: if the site is ever regenerated from the design 126 source, the old addresses come back in the output and this puts them 127 right again on load. Idempotent. */ 128 var DOMAINS = [ 129 ["kingsexhibition.framer.website", "thekingsexhibition.com"], 130 ["bookbankfoundation.framer.website", "thebbf.org"], 131 ["bridgetothethrone.framer.website", "abridgetothethrone.com"] 132 ]; 133 134 function realDomains() { 135 var as = document.querySelectorAll('a[href*="framer.website"]'); 136 for (var i = 0; i < as.length; i++) { 137 var href = as[i].getAttribute("href") || ""; 138 for (var d = 0; d < DOMAINS.length; d++) { 139 if (href.indexOf(DOMAINS[d][0]) !== -1) { 140 as[i].setAttribute("href", "https://" + DOMAINS[d][1]); 141 break; 142 } 143 } 144 } 145 return as.length === 0; 146 } 147 148 /* ââ The hero's scroll parallax âââââââââââââââââââââââââââââââââââââââ */ 149 function stillHero() { 150 var hero = document.querySelector("section"); 151 if (!hero) return false; 152 var nodes = hero.querySelectorAll("div"); 153 for (var i = 0; i < nodes.length; i++) { 154 var s = nodes[i].getAttribute("style") || ""; 155 // the backdrop is the one the runtime drives with both a lift and a zoom 156 if (/scale\(1\.0[0-9]/.test(s) && /translateY\(-\d/.test(s)) { 157 nodes[i].setAttribute("data-gt-noparallax", ""); 158 return true; 159 } 160 } 161 return false; 162 } 163 164 /* ââ The Climb: drop the transition fighting the scroll ââââââââââââââââ */ 165 function freeClimb() { 166 var n = 0; 167 // Wide layout: the drawn line is the polyline carrying a dash array. 168 // (Also handled in CSS, since this component mounts lazily.) 169 var polys = document.querySelectorAll("polyline"); 170 for (var i = 0; i < polys.length; i++) { 171 if (polys[i].getAttribute("stroke-dasharray") && !polys[i].hasAttribute("data-gt-noease")) { 172 polys[i].setAttribute("data-gt-noease", ""); 173 n++; 174 } 175 } 176 // Narrow layout: the same component renders a vertical bar instead, and 177 // eases its `height` on scroll. Height cannot be matched by a CSS 178 // attribute selector, so find it by the property actually transitioning. 179 var divs = document.querySelectorAll("div"); 180 for (var k = 0; k < divs.length; k++) { 181 var d = divs[k]; 182 if (d.hasAttribute("data-gt-noease")) continue; 183 var st = d.getAttribute("style") || ""; 184 if (st.indexOf("calc(") === -1) continue; 185 var tp = window.getComputedStyle(d).transitionProperty || ""; 186 if (tp.indexOf("height") !== -1) { d.setAttribute("data-gt-noease", ""); n++; } 187 } 188 return n > 0; 189 } 190 191 /* ââ The Arrangement ââââââââââââââââââââââââââââââââââââââââââââââââââ */ 192 function findWorlds() { 193 var ps = document.querySelectorAll("p"); 194 var probe = null; 195 for (var i = 0; i < ps.length; i++) { 196 if (/Six rooms, one pulse/i.test(ps[i].textContent || "")) { probe = ps[i]; break; } 197 } 198 if (!probe) return null; 199 // walk out to the section, then find the row that holds the tiles 200 var sec = probe; 201 while (sec && sec !== document.body && sec.tagName !== "SECTION") sec = sec.parentElement; 202 if (!sec) return null; 203 var links = sec.querySelectorAll("a[href]"); 204 if (!links.length) return null; 205 var row = links[0].parentElement; 206 // every tile must share that parent, or we have the wrong node 207 var tiles = []; 208 for (var k = 0; k < row.children.length; k++) { 209 if (row.children[k].tagName === "A") tiles.push(row.children[k]); 210 } 211 return tiles.length >= 5 ? { section: sec, row: row, tiles: tiles } : null; 212 } 213 214 // In document order a tile reads: category, title(h3), claim, excerpt, 215 // chip, "Step inside". Structural, so no builder class names are needed. 216 function label(tile) { 217 var parts = tile.querySelectorAll("p, h3"); 218 var roles = ["category", "title", "claim", "excerpt", "chip", "cta"]; 219 for (var i = 0; i < parts.length && i < roles.length; i++) { 220 parts[i].setAttribute("data-gt-role", roles[i]); 221 } 222 // A tile is three bands: category strip, motif, body; the body is a top 223 // block over a footer. Tagging them structurally (not by builder class 224 // name) lets the stylesheet pin each footer to the bottom, so the chip 225 // and Step Inside button share one line across all six tiles. 226 var bands = tile.children; 227 if (bands.length >= 3) { 228 bands[0].setAttribute("data-gt-part", "cat"); 229 bands[1].setAttribute("data-gt-part", "motif"); 230 var body = bands[2]; 231 body.setAttribute("data-gt-part", "body"); 232 if (body.children.length >= 2) { 233 body.children[0].setAttribute("data-gt-part", "top"); 234 body.children[1].setAttribute("data-gt-part", "foot"); 235 } 236 } 237 return parts; 238 } 239 240 function buildTile(model) { 241 var tile = model.cloneNode(true); 242 tile.id = TILE_ID; 243 tile.setAttribute("href", BTT_URL); 244 tile.setAttribute("target", "_blank"); 245 tile.setAttribute("rel", "noreferrer"); 246 // the runtime may have left transition state on the source node 247 tile.style.removeProperty("opacity"); 248 tile.style.removeProperty("transform"); 249 250 var parts = label(tile); 251 var map = { category: BTT.category, title: BTT.title, claim: BTT.claim, excerpt: BTT.excerpt, chip: BTT.chip }; 252 for (var i = 0; i < parts.length; i++) { 253 var role = parts[i].getAttribute("data-gt-role"); 254 if (map[role]) parts[i].textContent = map[role]; 255 if (role === "chip") parts[i].setAttribute("data-gt-state", "soon"); 256 } 257 258 var img = tile.querySelector("img"); 259 if (img) { 260 img.setAttribute("src", COVER); 261 img.setAttribute("alt", "A Bridge to the Throne â book cover"); 262 img.removeAttribute("srcset"); 263 img.removeAttribute("sizes"); 264 img.setAttribute("loading", "lazy"); 265 img.setAttribute("width", "800"); 266 img.setAttribute("height", "1280"); 267 img.style.objectFit = "contain"; 268 } 269 return tile; 270 } 271 272 /* ââ Entrance, on one downbeat ââââââââââââââââââââââââââââââââââââââââ */ 273 function arm(el, kind, delay) { 274 if (!el || el.classList.contains("gt-rise") || 275 el.classList.contains("gt-rise-head") || el.classList.contains("gt-rise-thread")) return; 276 el.setAttribute("data-gt-delay", delay); 277 el.style.setProperty("--gt-delay", delay + "s"); 278 el.setAttribute("data-gt-kind", kind || "tile"); 279 } 280 281 // The class is added only at reveal time. Until then the element is in its 282 // normal, visible state, so nothing can strand it hidden. 283 function play(el) { 284 var kind = el.getAttribute("data-gt-kind") || "tile"; 285 el.classList.add(kind === "head" ? "gt-rise-head" : kind === "thread" ? "gt-rise-thread" : "gt-rise"); 286 } 287 288 function reveal(root, targets) { 289 var fired = false;
290 function go() { 291 if (fired) return; 292 fired = true; 293 // Browsers pause CSS animations in a background tab, and an animation 294 // that never starts would sit on its opening frame â invisible. There 295 // is nothing to watch in a hidden tab anyway, so skip the entrance and 296 // leave the section in its normal, visible state. 297 if (document.hidden) return; 298 for (var i = 0; i < targets.length; i++) play(targets[i]); 299 // Once the entrance has run, drop every trace of it. The resting state 300 // is the visible one, so cleanup can only ever leave tiles on screen. 301 window.setTimeout(function () { 302 for (var k = 0; k < targets.length; k++) { 303 targets[k].classList.remove("gt-rise", "gt-rise-head", "gt-rise-thread"); 304 targets[k].style.removeProperty("--gt-delay"); 305 } 306 }, 2000); 307 } 308 if (!("IntersectionObserver" in window)) { go(); return; } 309 var io = new IntersectionObserver(function (entries) { 310 if (entries[0] && entries[0].isIntersecting) { go(); io.disconnect(); } 311 }, { threshold: 0.15 }); 312 io.observe(root); 313 window.setTimeout(go, 4000); 314 } 315 316 function apply() { 317 var found = findWorlds(); 318 if (!found) return false; 319 var row = found.row, tiles = found.tiles; 320 321 if (!document.getElementById(TILE_ID)) { 322 for (var i = 0; i < tiles.length; i++) label(tiles[i]); 323 row.appendChild(buildTile(tiles[tiles.length - 1])); 324 } 325 row.setAttribute("data-gt-worlds", ""); 326 327 // chips read LIVE on three tiles and Live on two; normalise the wording 328 var chips = row.querySelectorAll('[data-gt-role="chip"]'); 329 for (var c = 0; c < chips.length; c++) { 330 if (/^live$/i.test(txt(chips[c]))) chips[c].textContent = "LIVE"; 331 } 332 333 // arm the entrance once 334 if (!row.hasAttribute("data-gt-armed")) { 335 row.setAttribute("data-gt-armed", ""); 336 var targets = []; 337 var head = found.section.querySelectorAll("h2, h3"); 338 var lead = head.length ? head[0].parentElement : null; 339 if (lead) { lead.setAttribute("data-gt-lead", ""); arm(lead, "head", 0); targets.push(lead); } 340 341 var all = row.children; 342 for (var t = 0; t < all.length; t++) { 343 arm(all[t], "tile", +(0.36 + t * STEP).toFixed(3)); 344 targets.push(all[t]); 345 } 346 reveal(found.section, targets); 347 } 348 return true; 349 } 350 351 /* ââ Parallax on the two book covers ââââââââââââââââââââââââââââââââ 352 Small images, transform only, written straight to the node inside a 353 damped rAF loop. Deliberately no CSS transition: a transition on a 354 scroll-linked value is exactly what makes a parallax trail the scroll. 355 356 Re-scans on every pass rather than latching a "done" flag. The band 357 re-inserts itself and the runtime re-renders, and a one-shot guard 358 meant that once those nodes were replaced the effect never came back. */ 359 var PX = { items: [], running: false, raf: 0, last: 0, wired: false }; 360 361 function pxTargets() { 362 var found = []; 363 var btt = document.querySelector(".gtbtt__cover"); 364 if (btt) found.push({ el: btt, range: 26 }); 365 366 // The Rhythm of Revenue band: its book is the transparent PNG inside 367 // the section carrying that band's copy. 368 var ps = document.querySelectorAll("p"); 369 for (var i = 0; i < ps.length; i++) { 370 if (/turned survival into strategy/i.test(ps[i].textContent || "")) { 371 var sec = ps[i]; 372 while (sec && sec !== document.body && sec.tagName !== "SECTION") sec = sec.parentElement; 373 if (sec) { 374 var pngs = sec.querySelectorAll('img[src$=".png"]'); 375 if (pngs.length) found.push({ el: pngs[0], range: 22 }); 376 } 377 break; 378 } 379 } 380 return found; 381 } 382 383 function pxTarget(it) { 384 var r = it.el.getBoundingClientRect(); 385 var vh = window.innerHeight || 1; 386 if (!r.height) return 0; 387 // -1 while the image sits below the fold, +1 once it has risen past it 388 var t = (r.top + r.height / 2 - vh / 2) / (vh / 2 + r.height / 2); 389 return -Math.max(-1, Math.min(1, t)) * it.range; 390 } 391 392 function pxFrame(now) { 393 PX.raf = 0; 394 var dt = PX.last ? Math.min(0.05, (now - PX.last) / 1000) : 0.016; 395 PX.last = now; 396 var moving = false;
397 for (var i = PX.items.length - 1; i >= 0; i--) { 398 var it = PX.items[i]; 399 if (!document.contains(it.el)) { PX.items.splice(i, 1); continue; } 400 var to = pxTarget(it); 401 var distance = to - it.shown; 402 if (Math.abs(distance) > 0.08) { 403 it.shown += distance * (1 - Math.exp(-dt / 0.12)); 404 moving = true; 405 } else { 406 it.shown = to; 407 } 408 it.el.style.transform = "translate3d(0," + it.shown.toFixed(2) + "px,0)"; 409 } 410 if (PX.running && PX.items.length && moving) { 411 PX.raf = window.requestAnimationFrame(pxFrame); 412 } else { 413 PX.running = false; 414 PX.last = 0; 415 for (var w = 0; w < PX.items.length; w++) PX.items[w].el.style.willChange = "auto"; 416 } 417 } 418 419 function pxStart() { 420 if (PX.running || document.hidden || !PX.items.length) return; 421 PX.running = true; PX.last = 0; 422 // Hold a layer only while the image is genuinely moving. The Rhythm of 423 // Revenue cover is 2090x2592, so a permanent hint is a costly texture 424 // to keep resident for an effect that only runs as it passes the screen. 425 for (var i = 0; i < PX.items.length; i++) PX.items[i].el.style.willChange = "transform"; 426 PX.raf = window.requestAnimationFrame(pxFrame); 427 } 428 function pxStop() { 429 PX.running = false; 430 if (PX.raf) window.cancelAnimationFrame(PX.raf); 431 PX.raf = 0; 432 for (var i = 0; i < PX.items.length; i++) PX.items[i].el.style.willChange = "auto"; 433 } 434 435 function parallax() { 436 if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return true; 437 438 // drop anything the runtime has detached, then adopt anything new 439 for (var i = PX.items.length - 1; i >= 0; i--) { 440 if (!document.contains(PX.items[i].el)) PX.items.splice(i, 1); 441 } 442 var found = pxTargets(); 443 for (var f = 0; f < found.length; f++) { 444 var el = found[f].el, known = false; 445 for (var k = 0; k < PX.items.length; k++) if (PX.items[k].el === el) { known = true; break; } 446 if (known) continue; 447 el.setAttribute("data-gt-parallax", ""); 448 var it = { el: el, range: found[f].range, shown: 0 }; 449 it.shown = pxTarget(it); 450 el.style.transform = "translate3d(0," + it.shown.toFixed(2) + "px,0)"; 451 PX.items.push(it); 452 } 453 if (!PX.items.length) return false; 454 455 if (!PX.wired) { 456 PX.wired = true; 457 document.addEventListener("visibilitychange", function () { 458 if (document.hidden) pxStop(); else pxStart(); 459 }); 460 window.addEventListener("scroll", pxStart, { passive: true }); 461 window.addEventListener("resize", pxStart, { passive: true }); 462 } 463 pxStart(); 464 return true; 465 } 466 467 // A page cached from an earlier build can still carry the old hidden 468 // class. Its stylesheet is gone, but strip the classes too so nothing lingers. 469 function clearLegacy() { 470 var stale = document.querySelectorAll(".gt-anim, .gt-in"); 471 for (var i = 0; i < stale.length; i++) { 472 stale[i].classList.remove("gt-anim", "gt-in", "gt-head", "gt-thread"); 473 stale[i].style.removeProperty("opacity"); 474 stale[i].style.removeProperty("transform"); 475 } 476 } 477 478 /* ââ Compositor layers ââââââââââââââââââââââââââââââââââââââââââââââ 479 The runtime marks every element carrying an appear or scroll effect 480 with `will-change: transform` and never takes it back. Each one is a 481 separate GPU layer held for the life of the page; on this homepage 482 that was 49 of them, several holding multi-megapixel artwork. The 483 browser has to build and upload that whole set on the first scroll, 484 which is what made the scroll off the hero feel stuck. 485 486 Anything still genuinely animating keeps its hint: elements running a 487 CSS animation (the marquees), and our own parallax while it is live. */ 488 function trimLayers() { 489 // Scan every element, not just inline styles: the hint arrives from 490 // stylesheet rules too, and those are the ones a narrower pass misses. 491 var els = document.querySelectorAll("body *"); 492 var freed = 0; 493 for (var i = 0; i < els.length; i++) { 494 var el = els[i]; 495 if (el.hasAttribute("data-gt-parallax")) continue; 496 var cs = window.getComputedStyle(el); 497 if (cs.willChange === "auto") continue; 498 if (cs.animationName && cs.animationName !== "none") continue; 499 if (cs.transitionProperty && cs.transitionProperty !== "none" && cs.transitionDuration !== "0s") continue; 500 el.style.setProperty("will-change", "auto", "important"); 501 freed++; 502 } 503 return freed; 504 } 505 506 function run() { 507 clearLegacy(); 508 integrateServices(); 509 stripDownArrows(); 510 realDomains(); 511 parallax(); 512 var hero = stillHero(); 513 var climb = freeClimb(); 514 var worlds = isHome ? !!document.getElementById(TILE_ID) : true; 515 if (isHome) worlds = apply() || worlds; 516 if (isHome && typeof window.__gtImpactApply === "function") window.__gtImpactApply(); 517 if (isHome && typeof window.__gtLegendaryApply === "function") window.__gtLegendaryApply(); 518 return { hero: hero, climb: climb, worlds: worlds }; 519 } 520 521 function boot() { 522 var tries = 0; 523 (function attempt() { 524 var st = run(); 525 // Keep trying until every piece has landed, not just the tile. The 526 // Climb's SVG mounts later than the rest of the page, so a loop that 527 // stops at the tile gives up before the line is ever reached. 528 // The Climb is handled in CSS (the component mounts lazily), so it is 529 // deliberately not part of this condition. 530 var done = st.hero && (!isHome || document.getElementById(TILE_ID)); 531 if (++tries < 60 && !done) window.setTimeout(attempt, 250); 532 })(); 533 534 // Once entrances have had time to play, hand the layers back. 535 window.setTimeout(trimLayers, 1600); 536 window.setTimeout(trimLayers, 4000); 537 538 // the runtime re-renders on breakpoint changes; put the work back 539 if ("MutationObserver" in window) { 540 var mo = new MutationObserver(function () { 541 if (window.__gtPass2Queued) return; 542 window.__gtPass2Queued = true; 543 window.setTimeout(function () { window.__gtPass2Queued = false; run(); }, 300); 544 }); 545 mo.observe(document.body, { childList: true, subtree: true }); 546 } 547 window.addEventListener("resize", function () { window.setTimeout(run, 300); }, { passive: true }); 548 } 549 550 if (document.readyState === "loading") { 551 document.addEventListener("DOMContentLoaded", boot); 552 } else {
553 boot(); 554 } 555})();
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.