feedBack/static/v3/shell.js
Byron Gamatos 9d6fdfe232
feat(v3): use PNG logo in sidebar nav instead of the text wordmark (#734)
Replaces the fee[dB]ack text wordmark in the #v3-brand sidebar header with the exported PNG logo (static/v3/brand/feedback-logo-light.png, 664x165). Sized width:100% + height:auto so it fits the 256px sidebar's content width (~208px inside the p-6). Updated both the no-JS fallback (index.html) and the shell.js boot render. Inline style avoids introducing a new Tailwind utility (constitution P-II).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:39:27 +02:00

408 lines
25 KiB
JavaScript

/*
* fee[dB]ack v0.3.0 — app shell: sidebar, topbar, client routing.
*
* Vanilla JS, no framework/bundler (constitution P-II). Reuses the engine
* from app.js: navigation is the shared window.showScreen across both the new
* #v3-* screens and the reused legacy/#plugin-* screens. UI placement is a
* DEFERRED capability domain, so plugin nav/screens are consumed via the
* legacy plugin loader + /api/plugins, NOT via capability dispatch
* (design/05-capability-pipelines.md). All v3 UI state uses the `v3:` prefix.
*/
(function () {
'use strict';
// HTML-escape untrusted strings (plugin manifest id/label) before they go
// into innerHTML, so a hostile/buggy manifest can't inject markup or event
// attributes into the sidebar plugin nav.
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// Plugin ids whose manifest declared `"fullscreen": true`. Populated from
// /api/plugins in renderPromotedNav(); read by syncActive() to toggle the
// immersive (chrome-collapsed) shell whenever such a plugin's screen is the
// active one. Empty until plugins resolve — the worst case is one extra
// syncActive() once the fetch lands, which re-applies the class.
const FULLSCREEN_PLUGIN_IDS = new Set();
// ── Navigation registry ────────────────────────────────────────────────
// Each entry maps a stable hash key → a screen id (showScreen target) and a
// label. Legacy screens are reused: "Songs" = #home (library), "Favorites"
// = #favorites, "Settings" = #settings. New screens use #v3-* ids.
const NAV = [
{ key: 'home', screen: 'v3-home', label: 'Home', group: 'HOME', icon: 'home' },
{ key: 'progress', screen: 'v3-progress', label: 'Progress', group: 'HOME', icon: 'trophy' },
{ key: 'shop', screen: 'v3-shop', label: 'Unlockables', group: 'HOME', icon: 'tag' },
{ key: 'feedbarcade', screen: 'v3-feedbarcade', label: 'FeedBarcade', group: 'HOME', icon: 'arcade' },
{ key: 'plugins', screen: 'v3-plugins', label: 'Plugins', group: 'HOME', icon: 'plug' },
{ key: 'settings', screen: 'settings', label: 'Settings', group: 'HOME', icon: 'gear' },
{ key: 'playlists', screen: 'v3-playlists', label: 'Playlists', group: 'LIBRARY', icon: 'list' },
{ key: 'songs', screen: 'v3-songs', label: 'Song Library', group: 'LIBRARY', icon: 'disc' },
{ key: 'lessons', screen: 'v3-lessons', label: 'Lessons', group: 'LIBRARY', icon: 'lessons' },
{ key: 'favorites', screen: 'favorites', label: 'Favorites', group: 'LIBRARY', icon: 'star' },
{ key: 'saved', screen: 'v3-saved', label: 'Saved for Later', group: 'LIBRARY', icon: 'bookmark' },
// Promoted plugins (group: null) — bundled plugins given their own
// first-class sidebar entry instead of the generic plugin gallery. They
// are placed by PROMOTED_PLUGINS below and their slots are filled by
// renderPromotedNav() only when the plugin is actually installed. All
// other plugins are reached solely via the single "Plugins" entry
// above. Screens are injected async by the plugin loader, so go()'s
// plugin- guard applies.
{ key: 'virtuoso', screen: 'plugin-virtuoso', label: 'Virtuoso - Practice', group: null, icon: 'target' },
{ key: 'rig_builder', screen: 'plugin-rig_builder', label: 'Rig Builder', group: null, icon: 'amp' },
{ key: 'editor', screen: 'plugin-editor', label: 'Song Editor', group: null, icon: 'edit' },
{ key: 'audio_engine', screen: 'plugin-audio_engine', label: 'Audio', group: null, icon: 'amp' },
// Not in the sidebar groups, but routable (profile badge → here).
{ key: 'profile', screen: 'v3-profile', label: 'Profile', group: null, icon: 'user' },
];
// Bundled plugins promoted to dedicated sidebar entries. `anchorAfter` is
// the nav key the slot is rendered immediately below (within that key's
// group); a key that's the last item of the last group lands right after
// that group. Each is gated on the plugin actually being installed.
const PROMOTED_PLUGINS = [
{ navKey: 'virtuoso', pluginId: 'virtuoso', slotId: 'v3-nav-virtuoso', anchorAfter: 'feedbarcade' },
{ navKey: 'rig_builder', pluginId: 'rig_builder', slotId: 'v3-nav-rig-builder', anchorAfter: 'saved' },
{ navKey: 'editor', pluginId: 'editor', slotId: 'v3-nav-editor', anchorAfter: 'songs' },
{ navKey: 'audio_engine', pluginId: 'audio_engine', slotId: 'v3-nav-audio-engine', anchorAfter: 'settings' },
];
const TOPBAR_KEYS = ['home', 'songs', 'plugins', 'settings'];
const SIDEBAR_GROUPS = ['HOME', 'LIBRARY'];
// Minimal inline icon set (currentColor 24x24 stroke paths).
const ICONS = {
home: 'M3 11l9-8 9 8M5 10v10h5v-6h4v6h5V10',
plug: 'M9 7V3m6 4V3M7 7h10v4a5 5 0 01-10 0V7zm5 9v5',
gear: 'M12 9a3 3 0 100 6 3 3 0 000-6zm8.4 3a8.4 8.4 0 00-.1-1.3l2-1.6-2-3.4-2.4 1a8 8 0 00-2.2-1.3l-.4-2.6H9.7l-.4 2.6A8 8 0 007.1 6l-2.4-1-2 3.4 2 1.6a8.4 8.4 0 000 2.6l-2 1.6 2 3.4 2.4-1a8 8 0 002.2 1.3l.4 2.6h4.6l.4-2.6a8 8 0 002.2-1.3l2.4 1 2-3.4-2-1.6c.1-.4.1-.9.1-1.3z',
list: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
disc: 'M12 3a9 9 0 100 18 9 9 0 000-18zm0 6a3 3 0 100 6 3 3 0 000-6z',
star: 'M12 3l2.9 6 6.6.9-4.8 4.6 1.1 6.5L12 18.8 6.2 21l1.1-6.5L2.5 9.9 9.1 9 12 3z',
bookmark: 'M6 3h12a1 1 0 011 1v17l-7-4-7 4V4a1 1 0 011-1z',
user: 'M12 12a4 4 0 100-8 4 4 0 000 8zm-7 8a7 7 0 0114 0',
arcade: 'M7 8h10a4 4 0 014 4 4 4 0 01-4 4H7a4 4 0 01-4-4 4 4 0 014-4zm0 4h3m-1.5-1.5v3M15 11h.01M17.5 13h.01',
lessons: 'M12 4L2 9l10 5 10-5-10-5zM6 11.5V16c0 1 2.7 2.5 6 2.5s6-1.5 6-2.5v-4.5',
trophy: 'M8 21h8m-4-4v4m-6-17h12v5a6 6 0 01-12 0V4zm12 2h2a2 2 0 01-2 4M6 6H4a2 2 0 002 4',
tag: 'M20.6 13.4l-7.2 7.2a2 2 0 01-2.8 0l-7-7V4h9.6l7.4 7.4a2 2 0 010 2zM7.5 7.5h.01',
amp: 'M4 5h16a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V6a1 1 0 011-1zm11 4a3 3 0 100 6 3 3 0 000-6zM6.5 8.5h.01M9 8.5h.01',
target: 'M12 3a9 9 0 100 18 9 9 0 000-18zm0 4a5 5 0 100 10 5 5 0 000-10zm0 4a1 1 0 100 2 1 1 0 000-2z',
edit: 'M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7M18.5 2.5a2.1 2.1 0 013 3L12 15l-4 1 1-4 9.5-9.5z',
};
function iconSvg(name) {
const d = ICONS[name] || ICONS.disc;
return '<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" ' +
'viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="' + d + '"/></svg>';
}
const byKey = (k) => NAV.find((n) => n.key === k);
const byScreen = (s) => NAV.find((n) => n.screen === s);
const currentScreenId = () => (document.querySelector('.screen.active') || {}).id || 'v3-home';
// The topbar IS each page's header row, so its title mirrors the screen.
function titleFor(screenId) {
if (screenId === 'v3-home') {
const p = (window.v3Profile && window.v3Profile.get && window.v3Profile.get()) || null;
return 'Welcome back, ' + ((p && p.display_name) || 'there') + '!';
}
const entry = byScreen(screenId);
if (entry) return entry.label;
if (screenId && screenId.indexOf('plugin-') === 0) return 'Plugins';
return '';
}
function setTopbarTitle(text) {
const el = document.getElementById('v3-topbar-title');
if (el) el.textContent = text || ''; // textContent → no escaping needed
}
// ── Active-state sync ───────────────────────────────────────────────────
function syncActive(screenId) {
const entry = byScreen(screenId);
document.querySelectorAll('[data-v3-nav]').forEach((el) => {
const on = entry && el.getAttribute('data-v3-nav') === entry.key;
el.classList.toggle('bg-fb-card', on);
el.classList.toggle('text-fb-text', on);
el.classList.toggle('text-fb-textDim', !on);
});
setTopbarTitle(titleFor(screenId));
// Immersive (full-screen) plugin screens: when the active screen belongs
// to a plugin that opted in via `"fullscreen": true`, collapse the host
// chrome (topbar hidden, sidebar → icon rail; see v3.css) and let the
// plugin own the content area. Toggled here so it tracks every
// navigation, including deep-link load and programmatic showScreen().
const fsPluginId = (screenId && screenId.indexOf('plugin-') === 0)
? screenId.slice('plugin-'.length) : null;
const immersive = !!(fsPluginId && FULLSCREEN_PLUGIN_IDS.has(fsPluginId));
document.documentElement.classList.toggle('fb-immersive', immersive);
// Show the song search only on the library screen. Everywhere else the
// box is irrelevant (and would silently no-op against v3Songs.search).
const searchWrap = document.getElementById('v3-search-wrap');
if (searchWrap) searchWrap.classList.toggle('hidden', screenId !== 'v3-songs');
// NOTE: we deliberately do NOT reflect the screen into location.hash on
// every navigation. app.js's audio 'error' handler suppresses empty-src
// errors only when `audio.src === window.location.href`; a `#/...`
// fragment makes href differ from the fragment-less resolved empty src,
// so screen-switch audio cleanup would log a spurious media error (and
// pollute the diagnostics console capture). Deep-linking IN is still
// supported on load (see boot()); live reflection OUT is intentionally
// omitted — it's optional per the prompt, console cleanliness is not.
}
// ── Navigation ──────────────────────────────────────────────────────────
function go(screenId) {
if (typeof window.showScreen !== 'function') return;
// Guard plugin screens that may not be injected yet (loadPlugins runs
// async at app.js boot). showScreen() throws on a missing element.
if (screenId.indexOf('plugin-') === 0 && !document.getElementById(screenId)) {
window.showScreen('v3-plugins');
return;
}
window.showScreen(screenId); // wrapper below re-syncs active state
closeMobileSidebar();
}
// ── Sidebar ───────────────────────────────────────────────────────────--
function navItemHTML(entry, labelOverride) {
return '<a href="#/' + entry.key + '" data-v3-nav="' + entry.key + '" ' +
'class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-fb-textDim ' +
'hover:text-fb-text hover:bg-fb-card/50 transition-colors">' +
iconSvg(entry.icon) + '<span class="truncate v3-nav-label">' + esc(labelOverride != null ? labelOverride : entry.label) + '</span></a>';
}
// Empty slot for a promoted plugin, anchored after a nav item. Filled by
// renderPromotedNav() only when the plugin is installed, so an absent
// bundle shows nothing rather than a dead entry that bounces to Plugins.
const promotedSlotHTML = (key) => PROMOTED_PLUGINS
.filter((p) => p.anchorAfter === key)
.map((p) => '<div id="' + p.slotId + '"></div>')
.join('');
function renderSidebar() {
const nav = document.getElementById('v3-nav');
if (!nav) return;
let html = '';
for (const group of SIDEBAR_GROUPS) {
const items = NAV.filter((n) => n.group === group);
if (!items.length) continue;
const itemsHTML = items.map((it) => navItemHTML(it) + promotedSlotHTML(it.key)).join('');
html += '<div><div class="v3-nav-group px-3 mb-1 text-[0.625rem] uppercase tracking-wider font-semibold text-fb-textDim/70">' +
group + '</div><div class="space-y-0.5">' + itemsHTML + '</div></div>';
}
nav.innerHTML = html;
nav.querySelectorAll('a[data-v3-nav]').forEach((a) => {
a.addEventListener('click', (e) => {
e.preventDefault();
go(byKey(a.getAttribute('data-v3-nav')).screen);
});
});
}
// ── Topbar ───────────────────────────────────────────────────────────---
// Funding cleared to come back online 2026-06-30 (offending functionality
// removed). feedBack-branded Patreon page.
const PATREON_URL = 'https://patreon.com/got_feedback';
function renderTopbar() {
const bar = document.getElementById('v3-topbar');
if (!bar) return;
bar.className = 'sticky top-0 z-30 bg-fb-sidebar/80 backdrop-blur';
bar.innerHTML =
// Row 1 — top utility bar: search.
'<div class="flex items-center gap-4 px-4 md:px-8 pt-4">' +
'<button id="v3-hamburger" class="md:hidden text-fb-textDim hover:text-fb-text shrink-0" aria-label="Menu">' +
'<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/></svg></button>' +
// Search is scoped to the library — syncActive() toggles `hidden` on
// this wrapper so it only shows on the v3-songs screen. It lives in
// the sticky topbar, so it stays put while the song grid scrolls.
'<div id="v3-search-wrap" class="flex-1 max-w-md relative hidden">' +
'<svg class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-fb-textDim" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path stroke-linecap="round" d="M21 21l-4-4"/></svg>' +
'<input id="v3-search" type="search" placeholder="Search songs…" aria-label="Search songs" ' +
'class="w-full bg-gray-800/50 border border-gray-700 rounded-md pl-10 pr-4 py-2 text-sm ' +
'text-fb-text placeholder-fb-textDim focus:border-fb-primary focus:ring-1 focus:ring-fb-primary outline-none"></div>' +
// Support Us! — stays on this top utility row (NOT the title row),
// pushed to the right with ml-auto; hidden on the smallest widths.
'<a href="' + PATREON_URL + '" target="_blank" rel="noopener" class="ml-auto ' +
'hidden sm:inline-flex items-center gap-2 bg-fb-accent hover:bg-red-600 text-white text-sm font-medium px-4 py-2 rounded-md shadow-lg shadow-fb-accent/20 transition-colors">' +
'<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M14.8 3c-3 0-5.4 2.4-5.4 5.4S11.8 13.9 14.8 13.9 20.2 11.5 20.2 8.4 17.8 3 14.8 3zM3.8 3h3.4v18H3.8z"/></svg>' +
'Support Us!</a>' +
'</div>' +
// Row 2 — page header: title + ONLY the tuner/instrument/profile
// badge cluster on the same line as the header.
'<div class="flex items-center gap-3 px-4 md:px-8 pt-2 pb-4">' +
'<h1 id="v3-topbar-title" class="text-2xl md:text-3xl font-bold text-fb-text truncate min-w-0 flex-1"></h1>' +
'<div class="flex items-center gap-2 shrink-0">' +
'<span id="v3-badge-tuner" class="contents"></span>' +
'<span id="v3-badge-instrument" class="contents"></span>' +
'<span id="v3-badge-profile" class="contents"></span>' +
'</div></div>';
const burger = document.getElementById('v3-hamburger');
if (burger) burger.addEventListener('click', toggleMobileSidebar);
setTopbarTitle(titleFor(currentScreenId()));
// Search drives the native Songs screen (prompt 21). Debounced so we
// don't refetch on every keystroke. Degrades silently if v3Songs isn't
// loaded yet.
const search = document.getElementById('v3-search');
if (search) {
let t = 0;
search.addEventListener('input', () => {
if (t) clearTimeout(t);
t = setTimeout(() => {
if (window.v3Songs && typeof window.v3Songs.search === 'function') window.v3Songs.search(search.value);
}, 250);
});
}
}
// ── Responsive sidebar ───────────────────────────────────────────────---
function ensureBackdrop() {
let bd = document.getElementById('v3-sidebar-backdrop');
if (!bd) {
bd = document.createElement('div');
bd.id = 'v3-sidebar-backdrop';
bd.className = 'fixed inset-0 bg-black/60 z-40 md:hidden hidden';
bd.addEventListener('click', closeMobileSidebar);
document.body.appendChild(bd);
}
return bd;
}
function toggleMobileSidebar() {
const sb = document.getElementById('v3-sidebar');
const bd = ensureBackdrop();
if (!sb) return;
const opening = sb.classList.contains('hidden');
sb.classList.toggle('hidden', !opening);
// On mobile, float the sidebar over content.
sb.classList.toggle('flex', opening);
sb.classList.toggle('fixed', opening);
sb.classList.toggle('inset-y-0', opening);
sb.classList.toggle('left-0', opening);
sb.classList.toggle('z-50', opening);
bd.classList.toggle('hidden', !opening);
}
function closeMobileSidebar() {
if (window.matchMedia && window.matchMedia('(min-width: 768px)').matches) return;
const sb = document.getElementById('v3-sidebar');
const bd = document.getElementById('v3-sidebar-backdrop');
if (sb) {
sb.classList.add('hidden');
sb.classList.remove('flex', 'fixed', 'inset-y-0', 'left-0', 'z-50');
}
if (bd) bd.classList.add('hidden');
}
// ── Promoted-plugin nav (legacy loader is the source; UI domain deferred) ─
// Individual plugins are NOT listed in the sidebar — the single "Plugins"
// entry (HOME group) is the one entry point to the plugin gallery. The
// PROMOTED_PLUGINS get their own first-class slots, each filled here only
// when that plugin is actually installed.
async function renderPromotedNav() {
let plugins = [];
try {
const res = await fetch('/api/plugins');
if (res.ok) plugins = await res.json();
} catch (e) { return; } // degrade: no promoted slots
const list = Array.isArray(plugins) ? plugins : [];
// Record which installed plugins requested immersive (full-screen)
// screens, then re-sync the active screen so the chrome collapses even
// if we navigated to a fullscreen plugin before /api/plugins resolved.
FULLSCREEN_PLUGIN_IDS.clear();
for (const p of list) { if (p && p.fullscreen && p.id) FULLSCREEN_PLUGIN_IDS.add(p.id); }
try { syncActive(currentScreenId()); } catch (e) { /* non-fatal */ }
for (const promo of PROMOTED_PLUGINS) {
const host = document.getElementById(promo.slotId);
const entry = byKey(promo.navKey);
if (!host || !entry) continue;
const plugin = list.find((p) => p && p.id === promo.pluginId);
if (!plugin) continue; // not installed → empty slot
// Use the plugin's own nav label (manifest), falling back to the
// static NAV label. navItemHTML escapes it.
const label = (plugin.nav && plugin.nav.label) || plugin.name || entry.label;
host.innerHTML = '<div class="space-y-0.5">' + navItemHTML(entry, label) + '</div>';
const a = host.querySelector('a[data-v3-nav]');
if (a) a.addEventListener('click', (e) => { e.preventDefault(); go(entry.screen); });
}
}
// ── showScreen wrapper (idempotent rehydration — design/05 §Rehydration) ─
function installShowScreenHook() {
const hooks = window.__feedBackV3ShellHooks || (window.__feedBackV3ShellHooks = {});
hooks.syncActive = syncActive; // always point at the latest impl
if (hooks.installed) return;
hooks.installed = true;
hooks.baseShowScreen = window.showScreen;
window.showScreen = function (id) {
// Route every "go to the library" navigation to the v3 native Songs
// screen instead of the legacy #home library, so player-close,
// settings-back, the hidden legacy navbar, etc. all stay in v3.
const target = (id === 'home') ? 'v3-songs' : id;
const r = hooks.baseShowScreen ? hooks.baseShowScreen.call(this, target) : undefined;
try { hooks.syncActive && hooks.syncActive(target); } catch (e) { /* non-fatal */ }
return r;
};
}
// ── Boot ────────────────────────────────────────────────────────────────
async function boot() {
var _v3brand = document.getElementById('v3-brand');
if (_v3brand) _v3brand.innerHTML = '<img src="/static/v3/brand/feedback-logo-light.png" alt="fee[dB]ack" style="width:100%;height:auto;display:block">';
renderSidebar();
renderTopbar();
ensureBackdrop();
installShowScreenHook();
renderPromotedNav(); // async, non-blocking
// First-run gate: onboarding overlay is owned by prompt 15. Until it
// exists, degrade gracefully and go straight to the dashboard.
let profile = null;
try {
const res = await fetch('/api/profile');
if (res.ok) profile = await res.json();
} catch (e) { /* profile endpoint lands in prompt 15 */ }
if (profile && profile.onboarded === false && window.v3Onboarding && typeof window.v3Onboarding.show === 'function') {
try { window.v3Onboarding.show(profile); } catch (e) { /* fall through */ }
}
// Splitscreen pop-out windows (`?ssFollower=1`) get sent to
// showScreen('player') by app.js's bootstrap (app.js:9716) before
// this runs, exactly so the library doesn't flash on the popup.
// Don't undo that here — the splitscreen IIFE loads next and takes
// the popup the rest of the way into follower mode. Without this
// bail, the default 'v3-home' target below would re-activate the
// library screen and bring the flash back.
let isFollowerWindow = false;
try { isFollowerWindow = new URLSearchParams(location.search).get('ssFollower') === '1'; }
catch (_) { /* file:// or sandboxed iframe — fall through */ }
if (isFollowerWindow) {
syncActive('player');
} else {
// Deep-link IN on load: honor a #/key fragment, then strip it so
// subsequent screen-switch audio cleanup doesn't trip app.js's
// href-based empty-src guard (see syncActive). #v3-home is already
// `.active` in the HTML, so when that's the target we only sync the
// chrome — calling showScreen() redundantly would run its non-player
// teardown branch and log a spurious "Empty src" media error.
const m = (location.hash || '').match(/^#\/([\w-]+)/);
const entry = m && byKey(m[1]);
if (location.hash) {
try { history.replaceState(null, '', location.pathname + location.search); } catch (e) { /* file:// */ }
}
const target = entry ? entry.screen : 'v3-home';
const el = document.getElementById(target);
if (el && el.classList.contains('active')) {
syncActive(target);
} else {
go(target);
}
}
// The "Welcome back, {name}!" title needs the profile, which loads
// async — refresh once it's in (and whenever it changes).
function refreshHomeTitle() { if (currentScreenId() === 'v3-home') setTopbarTitle(titleFor('v3-home')); }
if (window.feedBack && typeof window.feedBack.on === 'function') {
window.feedBack.on('v3:profile-updated', refreshHomeTitle);
}
setTimeout(refreshHomeTitle, 700);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})();