1// The site bar: brand, a dropdown menu, and the wallet corner. 2// 3// Built here rather than copied into seventeen pages, because the last few 4// header changes had to be applied seventeen times and a page was always one 5// edit behind. Every page keeps only an empty <header> and this fills it. 6// 7// app.js binds to #connect-btn, #wallet-addr and #wallet-area, and injects the 8// sound toggle and coin chip into that container. So this must run BEFORE 9// app.js and must produce those exact elements â the wallet corner is this 10// bar's markup but still app.js's behaviour. 11(() => { 12 const header = document.querySelector('header'); 13 if (!header) return; 14 15 const here = location.pathname.replace(/\/$/, '') || '/'; 16 17 const LINKS = [ 18 { href: '/arcade', label: 'Arcade' }, 19 { href: '/duel', label: 'Duels' }, 20 { href: '/crews', label: 'Crews' }, 21 { href: '/leaderboard', label: 'Leaderboard' }, 22 // CLAIMS IS GONE FROM THE BAR, because the desk it pointed at is shut. 23 // The entry read `{ href: '/prizeroom', label: 'Claims' }` and was called 24 // Claims rather than Prizes on the reasoning that there are no prizes, 25 // only a desk for tickets that already existed â and that desk closed at 26 // 00:00 UTC on 20 Sep 2026, the date /terms section 5 and prizeroom.html 27 // both carry. server.js now enforces it by date, so the link had become a 28 // nav item leading to a 410 on every page of the site. 29 // 30 // THE ROUTE IS NOT REMOVED AND MUST NOT BE. It still answers â with 410 31 // and one factual line â because holders were handed that URL and some 32 // will arrive late. Unlinking it from the furniture is not the same as 33 // deleting it, and deleting it would turn a withdrawn page into a dead end. 34 // See the claim desk block in server.js. 35 // { href: '/store', label: 'Store' }, <- paired with STORE_LIVE 36 ]; 37 38 // HOW TO PLAY came out of the bar and went into the footer below. Six items 39 // is where a nav starts being scanned rather than read, and of the six this 40 // is the one nobody clicks twice: it is read once, early, and then never 41 // again. It is NOT dropped â the rules of a game that pays out have to stay 42 // reachable from every page â so the footer link row exists to hold it, and 43 // /howtoplay is still on the Telegram bot's menu and in the landing footer. 44 const FOOT = [ 45 { href: '/howtoplay', label: 'How to play' }, 46 // TERMS and PRIVACY are absent from the footer, which no longer means what 47 // it used to. They were unlinked everywhere while the wording was being 48 // settled; the arcade floor now puts a gate in front of them that has to be 49 // agreed to before the floor is usable (public/tos.js), so both documents 50 // ARE linked, prominently, from the one page that matters. What is still 51 // missing is a link on every OTHER page, which is a smaller question than 52 // it was. Restore these two and the pair in landing.html's own footer row 53 // at the same time. 54 ]; 55 56 // THE STORE LINK IS COMMENTED OUT ABOVE, and putting it back is half of 57 // opening the store. The other half is STORE_LIVE=1 in the environment, 58 // which is what actually makes /store answer instead of 404ing. 59 // 60 // Two places rather than one because this file is the only script on every 61 // page, and having it fetch a flag on every page load â to decide a single 62 // nav link â costs a request everywhere to save an edit here. server.js says 63 // which half is missing in its boot log, so neither can be forgotten 64 // quietly: STORE_LIVE without this line is a store nobody can find, and this 65 // line without STORE_LIVE is a link to a 404. 66 67 const esc = window.esc || (v => String(v == null ? '' : v) 68 .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') 69 .replace(/"/g, '"').replace(/'/g, ''')); 70 71 // The header is drawn before anything knows whether a wallet is connected â 72 // wallet.js reconnects asynchronously at the bottom of the page. Rendering 73 // CONNECT WALLET and then swapping it for an address is the flash. So the 74 // last known session is remembered and used to draw the right header 75 // immediately; wallet.js corrects it if the reconnect fails. 76 let remembered = null; 77 try { remembered = JSON.parse(localStorage.getItem('webcade-wallet') || 'null'); } catch (_) {} 78 const pk = remembered && remembered.pk; 79 // A Telegram player has no wallet, so every route into /player was hidden 80 // from them: this menu item, the tab bar's profile tab, and the header link 81 // all key on `pk`. The page itself has always worked for a tg id â only the
82 // way in was missing â which meant anything living on a profile was 83 // invisible to every Telegram player, referral link included. 84 // 85 // tg.js runs before this file and publishes the id it read from initData. 86 // Display only, as it says: the server re-derives identity from the signed 87 // payload and never trusts this. 88 const tgId = (window.ARCADE_TG && window.ARCADE_TG.id) || null; 89 const profileFor = pk || tgId; 90 const shortPk = pk ? pk.slice(0, 4) + '..' + pk.slice(-4) : ''; 91 92 const items = LINKS.map(l => { 93 const on = here === l.href.replace(/\/$/, '') ? ' class="on"' : ''; 94 return `<a role="menuitem"${on} href="${l.href}">${esc(l.label)}</a>`; 95 }).join(''); 96 97 // Inside a Telegram Mini App the player is already identified, by a signed 98 // id rather than a wallet. Offering CONNECT WALLET there would be the exact 99 // dead end the Mini App exists to remove â so the header shows who they are 100 // instead. Defaults to "not in Telegram" on every page that never loads the 101 // bridge, which is all of them but /duel. 102 const TG = window.ARCADE_TG || { inTelegram: false, name: null }; 103 104 // Desktop shows the links inline; the hamburger menu carries them on a 105 // phone, plus the account items that never earn a place in the main row. 106 // 107 // PRIZE WALLET is Telegram-only and it is here because a player told us it 108 // was nowhere. The panel that says where their winnings get paid lived inside 109 // duel.js, so their name in the corner opened it on /duel and opened this 110 // menu â with nothing about a wallet in it â on every other page. The panel 111 // is on the player page now; this is the way in from everywhere else. A 112 // browser player needs no such link: their wallet is the chip itself. 113 // 114 // NO TELEGRAM GROUP AND NO BUY $TOKEN LINK. Both were menu items here and 115 // both were removed deliberately, so putting either back is a decision and 116 // not a tidy-up. The buy link pointed at the pump.fun page for the token: 117 // an account menu inside the game is the last place that should be routing 118 // players to a market, and shipping it alongside a prize board is the 119 // "play to earn" framing the site is being taken out of. The Telegram link 120 // went with it because the group is where the token gets talked about, and 121 // it is not linked from the footer row below either any more â see the note 122 // there. 123 header.innerHTML = ` 124 <div class="nav-inner"> 125 <a class="brand" href="/arcade" aria-label="Webcade home">WEB<i>CADE</i></a> 126 <nav class="nav-links" aria-label="Site">${items}</nav> 127 128 <div id="wallet-area"> 129 <button id="connect-btn" class="pixel-btn${TG.inTelegram || pk ? ' hidden' : ''}">Connect Wallet</button> 130 <span id="wallet-addr"${TG.inTelegram || pk ? '' : ' class="hidden"'}>${esc(TG.inTelegram ? (TG.name || 'player') : shortPk)}</span> 131 ${pk && remembered.free 132 ? `<span id="coin-balance" title="Free plays left today">${esc(remembered.free.left + (remembered.free.bonus || 0))}/${esc(remembered.free.allowance)}<i>plays</i></span>` 133 : pk && remembered.coins != null 134 ? `<span id="coin-balance">${esc(remembered.coins)}<i> coins</i></span>` : ''} 135 </div> 136 137 <button id="nav-toggle" aria-haspopup="true" aria-expanded="false" 138 aria-controls="nav-menu">☰</button> 139 <nav id="nav-menu" role="menu" hidden> 140 <div class="menu-links">${items}</div> 141 <a role="menuitem" id="nav-profile" class="${profileFor ? '' : 'hidden'}" href="${profileFor ? '/player?w=' + encodeURIComponent(profileFor) : '/'}">My profile</a> 142 ${tgId ? `<a role="menuitem" id="nav-prizewallet" href="/player?w=${encodeURIComponent(tgId)}#pl-wallet">Prize wallet</a>` : ''} 143 <button role="menuitem" id="nav-signin" class="hidden">Sign in</button> 144 <button role="menuitem" id="nav-disconnect" class="hidden">Disconnect wallet</button> 145 </nav> 146 </div>`; 147 148 // Shown only when a wallet is attached â including inside a Mini App, where 149 // a wallet from an earlier visit is invisible in the header but still stored 150 // on the device. That was the case with no way out at all. 151 // 152 // nav.js runs in the middle of the page and wallet.js at the foot, so 153 // ARCADE_WALLET does not exist yet when this file executes. Wiring it up 154 // immediately silently did nothing: the button stayed hidden and no listener 155 // was ever registered. So it wires on DOMContentLoaded, by which point every 156 // script at the end of the body has run. 157 const disc = document.getElementById('nav-disconnect'); 158 // Is there a browser wallet to let go of? A live connection (publicKey) OR one 159 // remembered on this device. The second half is why this now works inside 160 // Telegram: a Mini App proves its wallet into ARCADE_TG, never into 161 // publicKey, so gating on publicKey alone hid the control for the exact 162 // players who most need a way to swap the wallet their prizes go to. Reads 163 // localStorage live so the item disappears the moment a disconnect clears it. 164 const rememberedPk = () => { 165 try { return (JSON.parse(localStorage.getItem('webcade-wallet') || 'null') || {}).pk || null; } 166 catch (_) { return null; } 167 }; 168 function wireDisconnect() { 169 if (!disc) return; 170 const w = window.ARCADE_WALLET; 171 const has = () => !!((w && w.publicKey) || rememberedPk()); 172 const paintDisc = () => disc.classList.toggle('hidden', !has()); 173 paintDisc(); 174 if (w && w.onChange) w.onChange(paintDisc); 175 disc.addEventListener('click', async () => { 176 if (!has()) return; 177 const inTg = !!(window.ARCADE_TG && window.ARCADE_TG.inTelegram); 178 const msg = inTg 179 ? 'Forget the wallet stored on this device? Your Telegram account and prizes are not affected.' 180 : 'Disconnect this wallet?'; 181 // ARCADE_ASK, not window.confirm: inside the dApp Store build's WebView 182 // the native dialog is never drawn and answers false, so this guard was 183 // an unconditional return and the control did nothing. See ask.js. 184 if (!await window.ARCADE_ASK.confirm(msg, { ok: inTg ? 'FORGET' : 'DISCONNECT' })) return; 185 // Prefer the wallet's own teardown (clears its session token too); fall 186 // back to clearing the remembered record when there is no live wallet. 187 if (w && w.disconnect) { try { await w.disconnect(); } catch (_) {} } 188 else { try { localStorage.removeItem('webcade-wallet'); } catch (_) {} } 189 paintDisc(); 190 open(false); 191 }); 192 }
193 // THE WAY BACK IN, and the site did not have one until this existed. 194 // 195 // A session token expires and nothing refreshes it without a signature. The 196 // page used to sign in by itself on load, which is what made the dApp Store 197 // build unopenable on a Seeker â there, a signature is an app handoff that 198 // takes the arcade's task with it (see wallet.js token()). So page load is 199 // silent now, and silence needs somewhere for the player to say "yes, ask me". 200 // 201 // Without this the arcade opens fine and then quietly withholds everything a 202 // token buys â streak, rank, the daily reward â with no control anywhere that 203 // offers to fix it. A connected player with an aged-out session was stranded 204 // unless they happened to run out of free plays, because spending a coin was 205 // the only remaining path that still signed. 206 // 207 // Shown only when there is a live wallet AND no usable token: a Telegram 208 // player proves themselves with initData and never needs this, and a player 209 // holding a good token has nothing to do here. 210 const signin = document.getElementById('nav-signin'); 211 212 // NEVER CAPTURE ARCADE_WALLET HERE. This file is deferred and runs BEFORE 213 // wallet.js, and a deferred script executes with document.readyState already 214 // 'interactive' â so the guard at the foot of this file takes its `else` 215 // branch and wires everything at module time, when window.ARCADE_WALLET does 216 // not exist yet. Holding `const w = window.ARCADE_WALLET` there pins undefined 217 // forever: the predicate is false for good and onChange is never registered. 218 // 219 // That is exactly how this shipped broken. The item stayed hidden on a Seeker 220 // that needed it, with a live wallet and no token, and nothing said why â 221 // announce() swallows listener errors and there was no listener to fail. 222 // Disconnect sits two lines away and survives the same race only because its 223 // has() falls back to localStorage rather than trusting the object. 224 // 225 // So: look the wallet up at CALL time, and repaint when the menu opens, which 226 // is the only moment the answer is actually looked at. 227 const wallet = () => window.ARCADE_WALLET; 228 let repaintSignIn = () => {}; 229 function wireSignIn() { 230 if (!signin) return; 231 const inTg = () => !!(window.ARCADE_TG && window.ARCADE_TG.inTelegram); 232 const needs = () => { const w = wallet(); return !!(w && w.publicKey && !inTg() && !w.heldToken()); }; 233 const paintSignIn = () => signin.classList.toggle('hidden', !needs()); 234 repaintSignIn = paintSignIn; 235 paintSignIn(); 236 // Register as soon as there is something to register with. At module time 237 // there is not, so try again once the deferred scripts have all run. 238 const listen = () => { const w = wallet(); if (w && w.onChange) { w.onChange(paintSignIn); return true; } return false; }; 239 if (!listen()) { 240 document.addEventListener('DOMContentLoaded', () => { listen(); paintSignIn(); }, { once: true }); 241 window.addEventListener('load', () => { listen(); paintSignIn(); }, { once: true }); 242 } 243 signin.addEventListener('click', async () => { 244 const w = wallet(); 245 if (!w || !needs()) return; 246 signin.disabled = true; 247 const was = signin.textContent; 248 signin.textContent = 'Check your wallet\u2026'; 249 try { 250 await w.token(); 251 // Reload rather than invent a repaint. Everything personalised is 252 // fetched once on load â the dashboard, the rival strip, the daily 253 // reward â and each gave up quietly when there was no token. Asking 254 // them all to re-run is exactly what a load does, and a token that has 255 // just been signed for is worth one. 256 location.reload(); 257 } catch (_) { 258 // Refused, or the wallet never came back. Not an error worth a dialog: 259 // put the control back and let them try again. 260 signin.disabled = false; 261 signin.textContent = was; 262 paintSignIn(); 263 } 264 }); 265 } 266 267 function wireAccount() { wireDisconnect(); wireSignIn(); } 268 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wireAccount); 269 else wireAccount(); 270 271 const toggle = document.getElementById('nav-toggle'); 272 const menu = document.getElementById('nav-menu'); 273 274 function open(state) { 275 // The menu is the only place the sign-in item is ever read, and its answer 276 // depends on a token that can expire between one open and the next. 277 if (state) { try { repaintSignIn(); } catch (_) {} } 278 menu.hidden = !state; 279 toggle.setAttribute('aria-expanded', String(!!state)); 280 header.classList.toggle('menu-open', !!state); 281 } 282 toggle.addEventListener('click', e => { e.stopPropagation(); open(menu.hidden); }); 283 284 // The wallet corner opens the same menu, at every width. On a phone the 285 // hamburger already carries it; on a wider screen â Telegram Desktop, a 286 // tablet, any WebView past the 860px breakpoint â the hamburger is hidden and 287 // this is the only way in. Tapping your own name/address to reach My profile 288 // and Disconnect is also where people look for them. The account items 289 // (profile, buy, disconnect) live only in this menu, so without a trigger 290 // that survives every width they were unreachable â the bug this fixes. 291 const walletAddr = document.getElementById('wallet-addr'); 292 if (walletAddr) { 293 walletAddr.setAttribute('role', 'button'); 294 walletAddr.setAttribute('aria-haspopup', 'true'); 295 walletAddr.classList.add('wallet-menu-trigger'); 296 walletAddr.addEventListener('click', e => { 297 // A page may claim the chip for its own richer account menu â duel.js does, 298 // for the Telegram prize-wallet menu (#acct-menu), marking the chip with 299 // data-acct. Defer to it there rather than opening a second menu on top. 300 if (walletAddr.dataset.acct) return; 301 e.stopPropagation(); 302 open(menu.hidden); 303 }); 304 } 305 306 // a menu that only closes via its own button is a menu people get stuck in 307 document.addEventListener('click', e => { if (!menu.contains(e.target)) open(false); }); 308 document.addEventListener('keydown', e => { if (e.key === 'Escape') open(false); }); 309 310 // ---- the footer link row ---- 311 // 312 // The footer was a copyright line drawn in CSS with a ::after and nothing 313 // else, which was fine while HOW TO PLAY was in the bar. It is not fine now: 314 // taking a link out of a nav without putting it anywhere is deleting a page 315 // that still exists. Built here rather than pasted into thirty files, and it
316 // skips a footer that already has its own row â landing.html writes one, 317 // because the front door is static and never loads this file. 318 // 319 // THE TELEGRAM GROUP LINK WAS HERE and is gone on purpose, along with the 320 // copy of it in landing.html's own row and the two items in the account 321 // menu above. The group is where the token is discussed, and a link to it 322 // on every page of the arcade makes the arcade part of that. Nothing else 323 // referenced the invite hash, so the constant went with the last use of it: 324 // putting the link back means putting the hash back too, which is enough 325 // friction to make it a decision. 326 const foot = document.querySelector('footer'); 327 if (foot && !foot.querySelector('.foot-links') && !foot.querySelector('a')) { 328 const row = document.createElement('p'); 329 row.className = 'foot-links'; 330 row.innerHTML = FOOT.map(l => `<a href="${l.href}">${esc(l.label)}</a>`).join(''); 331 foot.prepend(row); 332 } 333 334 // ---- the UI switch ---- 335 // 336 // Yesterday's colours and wording, on a toggle. It goes in the footer link 337 // row rather than the header, and that is a decision worth stating: the bar 338 // has no spare room at 390px, and the header MENU â the obvious home for a 339 // preference â is only reachable past 860px by tapping the wallet corner, so 340 // a signed-out player on a desktop could not open it at all. The footer row 341 // is on every page, at every width, wallet or no wallet. 342 // 343 // Built here for the same reason the row above is: one control in one file 344 // rather than a copy in thirty-five documents. It attaches to whichever row 345 // the page has â the one just built, or the front door's own hand-written 346 // one, since landing.html has no <footer> and never gets the block above. 347 // 348 // role="switch" with aria-checked rather than a checkbox drawn to look like 349 // one: it is what makes a screen reader say "on" and "off" about it. 350 const footRow = document.querySelector('.foot-links') || document.querySelector('.land-foot'); 351 const UI = window.WEBCADE_UI; 352 if (footRow && UI) { 353 const sw = document.createElement('button'); 354 sw.type = 'button'; 355 sw.id = 'ui-switch'; 356 sw.className = 'ui-switch'; 357 sw.setAttribute('role', 'switch'); 358 sw.innerHTML = '<span class="ui-switch-label">Retro</span>' 359 + '<span class="ui-switch-track" aria-hidden="true"></span>'; 360 footRow.appendChild(sw); 361 // Subscribed rather than read once, so a page switched from somewhere else 362 // â the ?ui= query string, another tab â still shows the right state here. 363 // onChange calls back immediately, which is what sets it on load. 364 UI.onChange(on => { 365 sw.setAttribute('aria-checked', String(on)); 366 sw.title = on 367 ? 'Retro is on: the old colours and wording. Nothing has moved.' 368 : 'Switch to the old colours and wording. Nothing moves.'; 369 }); 370 sw.addEventListener('click', () => UI.toggle()); 371 } 372})();
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.