/** * openvibe-shared/appbar.js — the phone app bar: a site's main places as a tab bar fixed at the bottom of the screen, * as in an app (the owner, 2026-10-28: "on a phone it should act like an app… one app ecosystem across all our * systems"). navbar.js loads this file only for a site that asks for it, and draws it again on every render, page move * and setActive(); it needs nothing else. * * OpenVibeNavbar.init({ …, appBar: true }) the site's own links that carry an icon: the first four, and More * for the rest (it opens the navbar's drawer); five fit without More * OpenVibeNavbar.init({ …, appBar: [{ label, href, icon, id, page, active, dot }] }) five at most * OpenVibeNavbar.setAppBar(spec | false) change it later (a single-page app's sections) * * It shows under 760 px only. html.ov-has-appbar and --ovnav-appbar-h keep the page's last lines above it (kit.css's * dock and save bar sit on it), and it steps aside while the keyboard is up or a panel is open: all the way, without the * lift (4.234.0; with the lift still applied it stopped half on the screen above a keyboard or Android's toolbar gap). A link's icon is a * navbar icon name (fa-…), its dot a small red mark, the open page (its path, a path under it, or setActive's page) * a filled pill. Clicks go through the link's onClick and the site's onNavigate, as the navbar's own links do. * * Plan 279: the bar does not sit on the layout viewport's bottom edge. bottom.js measures the visible viewport * (--ovb-lift) and the bar moves up by it with `translate`, so the browser's toolbar sliding away or a keyboard * never leaves the bar behind; its height is measured back for whatever docks above it (--ovb-bar, --ovb-dock). */ (function (root) { 'use strict'; let barEl = null; let resizeBound = false; let pillWatch = null; function spec(ctx) { const cfg = ctx.config.appBar; if (!cfg) return null; const path = (typeof location !== 'undefined' && location.pathname) || '/'; const here = (l) => (l.active !== undefined ? !!l.active : ctx.activePage && l.page ? l.page === ctx.activePage : l.href === path || (l.href.length > 1 && l.href.startsWith('/') && path.startsWith(`${l.href.replace(/\/$/, '')}/`))); const usable = (l) => l && typeof l.href === 'string' && l.label && !l.hidden && !l.external; if (Array.isArray(cfg)) return { items: cfg.filter(usable).slice(0, 5).map((l) => Object.assign({}, l, { active: here(l) })), more: false }; const all = ctx.links.filter(usable); const withIcon = all.filter((l) => l.icon); const items = withIcon.slice(0, withIcon.length > 5 || all.length > withIcon.length ? 4 : 5); return { items: items.map((l) => Object.assign({}, l, { active: here(l) })), more: all.length > items.length }; } function styles() { if (document.getElementById('openvibe-appbar-styles')) return; const st = document.createElement('style'); st.id = 'openvibe-appbar-styles'; st.textContent = ` .ovnav-appbar { display: none; } @media (max-width: 760px) { html.ov-has-appbar { --ovnav-appbar-h: calc(58px + env(safe-area-inset-bottom, 0px)); } html.ov-has-appbar body { padding-bottom: var(--ovnav-appbar-h); } .ovnav-appbar { position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--ovb-z-bar, 9990); translate: 0 calc(-1 * var(--ovb-lift, 0px)); display: flex; align-items: stretch; height: var(--ovnav-appbar-h); box-sizing: border-box; padding: 0 4px env(safe-area-inset-bottom, 0px); background: color-mix(in srgb, var(--bg-secondary, #121826) 90%, transparent); -webkit-backdrop-filter: saturate(1.5) blur(16px); backdrop-filter: saturate(1.5) blur(16px); border-top: 1px solid var(--border, rgba(255,255,255,.1)); font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; transition: transform .2s ease, visibility 0s; } .ovnav-appbar .ovnav-ab { all: unset; box-sizing: border-box; flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; padding: 6px 2px; cursor: pointer; color: var(--text-muted, #93a0b4); font-size: 12px; font-weight: 650; line-height: 1.1; text-align: center; -webkit-tap-highlight-color: transparent; } .ovnav-appbar .ovnav-ab:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -4px; border-radius: 12px; } .ovnav-appbar .ovnav-ab.is-here { color: var(--text-primary, #e8ecf3); } .ovnav-appbar .ovnav-ab-ic { position: relative; display: grid; place-items: center; width: 52px; height: 28px; border-radius: 999px; font-size: 18px; transition: background .15s; } .ovnav-appbar .ovnav-ab.is-here .ovnav-ab-ic { color: var(--accent-light, var(--accent, #60a5fa)); } /* 4.70.0 (the owner, 2026-11-01: "improve the mobile button so it feels seamless and smooth"): one pill slides to the tab you are on (the bar is kept between pages, so it can), a tap lights its tab at once, a press gives and the new tab's icon pops. */ .ovnav-appbar .ovnav-ab-pill { position: absolute; left: 0; top: 0; width: 52px; height: 28px; border-radius: 999px; background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); transform: translate(var(--ab-x, 0px), var(--ab-y, 6px)); transition: transform .42s cubic-bezier(.2,1.25,.3,1), opacity .2s; pointer-events: none; } .ovnav-appbar.ab-still .ovnav-ab-pill { transition: none; } .ovnav-appbar:not(:has(.is-here)) .ovnav-ab-pill { opacity: 0; } .ovnav-appbar .ovnav-ab { -webkit-tap-highlight-color: transparent; } .ovnav-appbar .ovnav-ab-ic, .ovnav-appbar .ovnav-ab-l { position: relative; transition: transform .2s cubic-bezier(.2,1.4,.3,1), color .2s; } .ovnav-appbar .ovnav-ab:active .ovnav-ab-ic { transform: scale(.88); } .ovnav-appbar .ovnav-ab.is-here .ovnav-ab-ic svg { animation: ovAbPop .45s cubic-bezier(.2,1.5,.3,1); } .ovnav-appbar .ovnav-ab.is-here .ovnav-ab-l { font-weight: 700; } @keyframes ovAbPop { 0% { transform: scale(.7); } 60% { transform: scale(1.12); } 100% { transform: scale(1); } } .ovnav-appbar .ovnav-ab-ic svg { width: 1em; height: 1em; } .ovnav-appbar .ovnav-ab-l { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ovnav-appbar .ovnav-ab-dot { position: absolute; top: 2px; right: 12px; width: 8px; height: 8px; border-radius: 50%; background: var(--danger, #ef4444); box-shadow: 0 0 0 2px var(--bg-secondary, #121826); } html.ov-has-appbar:has(:is(input:not([type=checkbox]):not([type=radio]):not([type=range]), textarea, select, [contenteditable="true"]):focus) { --ovnav-appbar-h: 0px; } html.ov-has-appbar:has(:is(input:not([type=checkbox]):not([type=radio]):not([type=range]), textarea, select, [contenteditable="true"]):focus) .ovnav-appbar, body.ov-panel-open .ovnav-appbar { translate: none; transform: translateY(calc(100% + 24px)); visibility: hidden; pointer-events: none; transition: transform .2s ease, visibility 0s linear .2s; } } @media print { .ovnav-appbar { display: none !important; } }`; document.head.appendChild(st); } /** Slide the pill under the lit tab's icon (the bar is its offset parent). */ function placePill(bar, instant) { const pill = bar && bar.querySelector('.ovnav-ab-pill'); const ic = bar && bar.querySelector('.ovnav-ab.is-here .ovnav-ab-ic'); if (!pill || !ic) return; if (instant) bar.classList.add('ab-still'); // From where the icon and the bar are now (not offsetTop: a positioned tab or a late icon made that stale, and // the pill sat above its icon), centred on the icon both ways. const b = bar.getBoundingClientRect(), r = ic.getBoundingClientRect(); if (!r.width) return; bar.style.setProperty('--ab-x', `${Math.round(r.left - b.left - bar.clientLeft + (r.width - 52) / 2)}px`); bar.style.setProperty('--ab-y', `${Math.round(r.top - b.top - bar.clientTop + (r.height - 28) / 2)}px`); const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (f) => setTimeout(f, 16); if (instant) raf(() => raf(() => bar.classList.remove('ab-still'))); } /** Light one tab (and only it), as the page it opens. */ function light(bar, a) { bar.querySelectorAll('.ovnav-ab').forEach((t) => { const on = t === a; t.classList.toggle('is-here', on); if (on) t.setAttribute('aria-current', 'page'); else t.removeAttribute('aria-current'); }); placePill(bar); } const keyOf = (s) => s.items.map((l) => `${l.href}|${l.label}|${l.icon || ''}|${l.dot ? 1 : 0}`).join(',') + (s.more ? '+more' : ''); /** Draw (or take away) the bar for the navbar's current state: { config, links, activePage, nav, navIcon, escapeAttr, escapeHtml, flatLinks }. */ function render(ctx) { if (typeof document === 'undefined' || !document.body) return; const s = spec(ctx); const de = document.documentElement; // The same tabs as before: keep the bar (no flash, no jump) and move only what is lit, so the pill slides. if (barEl && barEl.isConnected && s && s.items.length >= 2 && barEl.getAttribute('data-ab-key') === keyOf(s)) { const tabs = barEl.querySelectorAll('a.ovnav-ab'); const lit = s.items.findIndex((l) => l.active); light(barEl, lit >= 0 ? tabs[lit] : null); return; } if (barEl) { barEl.remove(); barEl = null; } if (!s || s.items.length < 2) { if (de && de.classList) de.classList.remove('ov-has-appbar'); return; } styles(); const { navIcon, escapeAttr, escapeHtml } = ctx; const bar = document.createElement('nav'); bar.className = 'ovnav-appbar'; bar.setAttribute('aria-label', 'App'); bar.setAttribute('data-ab-key', keyOf(s)); bar.innerHTML = '' + s.items.map((l) => `${navIcon(l.icon || 'fa-circle')}${l.dot ? '' : ''}${escapeHtml(l.label)}`).join('') + (s.more ? `` : ''); bar.addEventListener('click', (e) => { if (e.target.closest && e.target.closest('[data-ab-more]')) { e.preventDefault(); e.stopPropagation(); const b = ctx.nav && ctx.nav.querySelector('#openvibe-burger'); if (b) b.click(); return; } const a = e.target.closest && e.target.closest('a.ovnav-ab'); if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; light(bar, a); const id = a.getAttribute('data-link-id'); const item = id ? ctx.flatLinks(ctx.links).concat(Array.isArray(ctx.config.appBar) ? ctx.config.appBar : []).find((l) => l && l.id === id) : null; if (item && typeof item.onClick === 'function' && item.onClick(e) === false) e.preventDefault(); if (e.defaultPrevented) return; if (typeof ctx.config.onNavigate === 'function') { let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ } if (same && ctx.config.onNavigate((() => { try { const u = new URL(a.href, location.href); return `${u.pathname}${u.search}${u.hash}`; } catch { return a.getAttribute('href'); } })(), e) === false) e.preventDefault(); } }); document.body.appendChild(bar); barEl = bar; if (de && de.classList) de.classList.add('ov-has-appbar'); if (root.OpenVibeBottom) root.OpenVibeBottom.refresh(); placePill(bar, true); if (!resizeBound && typeof window !== 'undefined' && typeof window.addEventListener === 'function') { resizeBound = true; window.addEventListener('resize', () => placePill(barEl, true)); } // The icons and the brand font arrive after the bar is drawn, and a tab's label can wrap: place it again whenever // the bar or the lit icon changes size, and once the fonts are in. if (typeof ResizeObserver === 'function') { if (pillWatch) pillWatch.disconnect(); pillWatch = new ResizeObserver(() => placePill(barEl, true)); pillWatch.observe(bar); bar.querySelectorAll('.ovnav-ab-ic').forEach((el) => pillWatch.observe(el)); } if (document.fonts && document.fonts.ready && typeof document.fonts.ready.then === 'function') document.fonts.ready.then(() => placePill(barEl, true)).catch(() => {}); } const api = { render, spec }; if (typeof module === 'object' && module.exports) module.exports = api; root.OpenVibeAppBar = api; })(typeof globalThis !== 'undefined' ? globalThis : this);