openvibers/openvibe.live Commits

The home page no longer downloads the chat UI (chat-core.js, the chatUI route feature, budgets), and warms it at idle (#672)

OP OpenVibers committed 6 days ago · 688c64063c · 16 files, +671 −336

* Chat core: the part of chat every page needs, out of chat.js

The chat UI and the part of chat that every page needs were one file, so a
page without chat still downloaded 340 KB of chat code. This is the first
step of the split: public/js/chat-core.js takes the pieces that belong to
every page — toast(), the chat settings (this browser's copy and the
server's, synced on every page), the floating chat button's visibility
(the button is in index.html on every page) and the ?vcInvite= deep link
(a notification can land on any page) — and chat.js keeps the chat UI.

No behaviour change: the moved code is the same code, only its home
changes. index.html and the popout chat page load chat-core.js before
chat.js; both are plain scripts, so they share one global scope. The
budgets move up by the size of the new file (jsFiles 26 → 27, jsRawKB
1308.1 → 1311.8, jsBrotliKB 306 → 307.8, htmlRawKB 137.7 → 137.9); the
chat UI leaves the home page in the next step, which brings them down.

Checked: npm test -- chat features perf-budget first-paint route-skeletons
shell-one-h1 asset-versioning css-syntax page-status look-boot (35 files,
all passed) and the browser smoke against a local server (all checks
passed, including the chat rendering and staying pinned to the bottom).

* The chat UI is a lazy feature: the home page stops downloading it

public/js/chat.js (340 KB) with chat-shared.js and chat-shared.css is the
chat: the socket, the messages, the panels, the moderation. Only the pages
that have chat need it, so it becomes a feature (features.json → chatUI)
that the routes /@…, /stream/…, /chat and /broadcast name.

It is a route feature and not a dependency of channel, so the home page's
idle prefetch of the channel page does not carry it either. The home page
now ships 25 scripts and 233 KB brotli of JavaScript, down from 27 and
308 KB; the chat arrives when someone goes to a channel, or when they
press the floating global chat button (its handlers are stubs until the
feature has loaded).

The pages that have chat ask for the chat UI before they talk to it
(withChatUi in chat-core.js), because the route's scripts and the chat's
arrive side by side. The home page's user menu — a name on a card — fetches
the chat UI and opens the same menu it always did.

Checked: npm test -- chat features perf-budget first-paint route-skeletons
shell-one-h1 asset-versioning css-syntax page-status look-boot (36 files,
all passed) and the browser smoke against a local server (all checks
passed, including the new ones: the home page does not download the chat
UI, and the global chat button brings it and opens the widget).

* The home page's budgets come down to its new size

The chat UI is a lazy feature now (the previous step), so the home page ships 25
scripts and 233.2 KB of brotli instead of 27 and 307.8. The JavaScript budgets
follow the page down, and the chat's own files join the list of code that may
never be part of the home page's first load again — the check now fails if
public/js/chat.js, public/js/chat-shared.js or public/css/chat-shared.css come
back to the shell.

The measurement is in docs/performance-audit.md: the phone asks the home page
for 374 KB less (41 scripts and 13 stylesheets down to 40 and 12), and the home
page's DOMContentLoaded falls by ~2.7 s on a throttled phone. The channel page
is unchanged in bytes; its chat's download moved from the home page to the
channel page, which the audit records as the trade.

* The TTS opt-in test reads the settings where they live now

The chat settings moved to public/js/chat-core.js with the rest of the part of
chat every page needs, so the test that checks the defaults and the synced
record reads that file; the panel and the viewer branches stay in chat.js.

* Merge branch 'perf/lazy-chat-core' into perf/lazy-chat-ui

* Merge branch 'perf/lazy-chat-ui' into perf/lazy-chat-size

* Merge main into the chat-core branch

Main moved on (openvibe-shared 4.133.0, the hero and the tagged sounds), and the
home page's budgets are measured against it: with the chat core split out of
chat.js the home page is 27 files and 1314.2 KB (308.3 brotli), one more script
tag than main's 26 and 1310.5. The budget notes say so.

* Merge the chat-core branch into the chatUI branch

Main moved on under the chat-core branch (openvibe-shared 4.133.0), so the home
page's budgets are measured against it: the chat UI feature adds 0.8 KB to the
HTML (the asset manifest carries the registry, which now names the chat UI and
its stubs) and takes the chat's 355 KB of JavaScript off the home page.

* Merge the chatUI branch into the size branch, and measure against main

Main moved on under the stack (openvibe-shared 4.133.0), so the measurement is
taken again against it: the home page is 25 files and 955.2 KB (233.7 brotli),
down from main's 26 files and 1310.5 KB (306.5 brotli), and the budgets follow
it down. The phone asks the home page for 374 KB less (41 scripts and 13
stylesheets down to 40 and 12), and the home page's DOMContentLoaded falls by
~2.5 s on a throttled phone.

* Home warms the chat at idle: chatUI joins the home's idle prefetch, so the channel opened next from home has its chat cached (it still never runs on home)

* Merge remote-tracking branch 'origin/main' into perf/lazy-chat-size

* chat-ui-lazy test: the home never loads the chat UI and warms it at idle by name (the Opus decision), never as a dependency

Changes

ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex 8ae39b18..e0b48591 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -49,6 +49,9 @@ public/js/ov-loader.js configures /shared/web-runtime.js (openvibe-shared): ov
route generations, scopes, stubs, transactional asset groups, ov.diagnostics()/ov.leaks()
public/js/app.js core: router, auth, API helper, shared renderers, connection pill
public/js/app-*.js route code split out of app.js: home, channel, media, chatpage, docs
++public/js/chat-core.js the part of chat every page needs: toast(), the chat settings, the floating chat
++ button's visibility, the ?vcInvite= deep link. The chat UI itself (public/js/chat.js)
++ belongs to the pages that have chat.
```
- **First paint.** `server/web/assets.js` renders `index.html` for the requested path: the route's
@@@ -70,6 +73,15 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c
Community cannot answer, the page is 200, never a false 404.
- **Stubs.** Inline handlers such as `onclick="openSetupHub()"` work before a feature is loaded: the
stub loads the feature, then calls the real function.
++- **The chat is a lazy feature.** The chat UI (`public/js/chat.js` — the socket, the messages, the
++ panels, the moderation — with `public/js/chat-shared.js` and `public/css/chat-shared.css`) is 340 KB
++ of JavaScript, so only the pages that have chat ask for it: the routes `/@…`, `/stream/…`, `/chat`
++ and `/broadcast` name the feature `chatUI` (public/features.json). It is a route feature and not a
++ dependency of `channel`, so the home page's idle prefetch of the channel page does not carry it.
++ The part of chat that every page needs — `toast()`, the chat settings, the floating chat button's
++ visibility, the `?vcInvite=` deep link — is `public/js/chat-core.js`. The chat UI's inline handlers
++ (the floating button, the chat pane's buttons) are in the feature's `stubs`, so a click before it
++ has loaded fetches it and then does the thing (`test/chat-ui-lazy.test.js`).
- **Prefetch.** Hover or keyboard focus on an in-site link prefetches that route's files
(`<link rel=prefetch>`, download only); a route's `idle` list is prefetched after load. Skipped on
Save-Data, 2G and low-memory devices.
ddiff --git a/docs/performance-audit.md b/docs/performance-audit.md
iindex 237a048e..250655ea 100644
---- a/docs/performance-audit.md
++++ b/docs/performance-audit.md
@@@ -201,6 +201,49 @@ The throttled-network `--phone` profile still shows the home's first paint at 3.
share the 1.6 Mbps link with ~30 deferred scripts). Real-user data (#217) is the arbiter now: compare
`live_rum_p75{page="home",device="phone"}` before restructuring the stylesheets.
++## Measurement 2026-11-07: the chat UI is a lazy feature
++
++The chat (`public/js/chat.js`, 326 KB, plus `chat-shared.js`) used to be in the home page's shell:
++every page — the home, the dashboard, a channel — downloaded the whole chat before it painted
++anything. It is now a feature (`public/features.json` → `chatUI`) that only the routes with chat
++ask for, and `public/js/chat-core.js` (19 KB: the toast, the chat settings, the floating button,
++the `?vcInvite=` link) stays in the shell for the pages that need those.
++
++`npm run perf:budget` (the server's own render of "/", deterministic — before is `main` at 71ba7ec,
++openvibe-shared 4.133.0):
++
++| | before | after |
++|---|---:|---:|
++| home HTML | 137.7 KB (23.8 br) | 138.6 KB (24.1 br) — the asset manifest now carries the chatUI feature |
++| home JS | 26 files, 1310.5 KB (306.5 br) | **25 files, 955.2 KB (233.7 br)** |
++| home CSS | 5 files, 177.5 KB (35.4 br) | 5 files, 177.5 KB (35.4 br) — `chat-shared.css` left with the chat |
++
++What the browser asked for, on a 390×844 phone (`tools/phone-speed.js` profile, local server):
++
++| page | before | after |
++|---|---|---|
++| `/` | 41 scripts (1787.3 KB) + 13 stylesheets (862.5 KB) | 40 scripts (1432.1 KB) + 12 stylesheets (843.6 KB) — **−374 KB, −14%** |
++| `/@admin` | 50 scripts (2072.4 KB) + 13 stylesheets (815.9 KB) | 51 scripts (2076.8 KB) + 13 stylesheets (815.9 KB) — unchanged |
++
++The channel page is unchanged because the server still puts the chat in its HTML: a direct visit to
++`/@x` downloads the chat in parallel with the rest of the page, exactly as before. What moved is
++*when* it is paid for — a visit that starts on the home page no longer pays for a chat it may never
++open. The cost is on the home → channel path inside one session: the chat is downloaded when the
++channel is entered rather than at the home page, so on a cold phone the chat's first paint is about
++0.5 s later (91.6 KB of Brotli at 1.6 Mbps); a repeat visit is unaffected (the browser has it), and
++the loader's intent prefetch (hover, focus or touch on a channel link) warms it before the click.
++
++Timings on the throttled phone profile (4× CPU, 1.6 Mbps, 150 ms), two runs each, same box, both
++states on local servers: the home page's DOMContentLoaded fell from 14078/14520 ms to 11488/12219 ms
++(the page has 374 KB less JavaScript to parse — this held in every run). The LCP (the hero quip,
++which waits for `/api/home/hero`) is inside the noise band of this shared box: the runs gave
++3888/4964 ms before and 3952/15776 ms after, the second "after" run being an outlier on the API
++call, so no LCP result is claimed. The channel page's DOMContentLoaded moved 5381/5458 →
++7381/7491 ms: the chat's download moved from the home page to the channel page.
++
++`scripts/perf/check-budgets.js` now refuses to let `/js/chat.js`, `/js/chat-shared.js` or
++`/css/chat-shared.css` back onto the home page, next to the size budgets it just lowered.
++
## Budgets
`npm run perf:budget` (scripts/perf/check-budgets.js) fails if the home page's raw HTML, eagerly
ddiff --git a/public/features.json b/public/features.json
iindex e80ef817..9f8c48a7 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -1,6 +1,33 @@
{
"_comment": "Feature and route registry, read by server/web/assets.js (first paint: which assets the requested route needs) and by public/js/ov-loader.js (navigation: what to load before a route renders). Scripts in a feature run in the listed order, after the feature's dependencies. `fragment` names public/fragments/<name>.html, inserted into section #page-<section> before any of the feature's scripts run. `after` names a global function called once when the feature has loaded. `stubs` are global functions that inline handlers call; until the feature is loaded they load it and then call the real function. See docs/architecture.md#frontend-loading.",
"features": {
++ "chatUI": {
++ "_comment": "The chat UI: the socket, the messages, the panels. Only the pages that have chat ask for it (the routes below name it); the part of chat every page needs is public/js/chat-core.js.",
++ "css": [
++ "/css/chat-shared.css"
++ ],
++ "js": [
++ "/js/chat-shared.js",
++ "/js/chat.js"
++ ],
++ "stubs": [
++ "sendChat",
++ "toggleChatTTS",
++ "toggleChatSettings",
++ "toggleChatUsers",
++ "toggleChatLimitsPopover",
++ "toggleMobileChat",
++ "popoutStreamChat",
++ "popoutGlobalChat",
++ "onChatModeChange",
++ "fcwToggle",
++ "fcwMinimize",
++ "fcwClose",
++ "fcwSendChat",
++ "fcwStartDrag",
++ "fcwStartResize"
++ ]
++ },
"home": {
"fragment": "home",
"css": [
@@@ -20,7 +47,8 @@
"idle": [
"guide",
"player",
-- "channel"
++ "channel",
++ "chatUI"
]
},
"feeds": {
@@@ -360,12 +388,14 @@
{
"path": "^/broadcast",
"features": [
++ "chatUI",
"broadcast"
]
},
{
"path": "^/chat",
"features": [
++ "chatUI",
"chatpage"
]
},
@@@ -392,6 +422,7 @@
{
"path": "^/stream/",
"features": [
++ "chatUI",
"channel"
]
},
@@@ -404,6 +435,7 @@
{
"path": "^/@",
"features": [
++ "chatUI",
"channel"
]
}
ddiff --git a/public/index.html b/public/index.html
iindex fa5b5deb..5902c0cb 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -117,11 +117,13 @@
their rules sit in the cascade exactly where they sat inside style.css. -->
<meta name="ov-css-anchor">
<link rel="stylesheet" href="/css/i18n-star.css">
-- <link rel="stylesheet" href="/css/chat-shared.css" media="print" onload="this.media='all';this.onload=null">
++ <!-- chat-shared.css belongs to the chat (features.json → chatUI): the pages that have chat ask
++ for it there, and a page without chat does not download it. -->
<link rel="stylesheet" href="/css/broadcast.css" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/cosmetics.css" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/messenger.css" media="print" onload="this.media='all';this.onload=null">
<noscript>
++ <!-- Without JavaScript the chat never fills, but the pages that have one still show it. -->
<link rel="stylesheet" href="/css/chat-shared.css">
<link rel="stylesheet" href="/css/broadcast.css">
<link rel="stylesheet" href="/css/cosmetics.css">
@@@ -443,11 +445,12 @@
<script src="/shared/navbar.js" defer></script>
<script src="/js/app.js" defer></script>
<script src="/js/ov-navbar-live.js" defer></script>
--<script src="/js/chat-shared.js" defer></script>
<script src="/js/lang-detect.js" defer></script>
--<script src="/js/chat.js" defer></script>
++<script src="/js/chat-core.js" defer></script>
<!-- (js/admin.js removed — 130KB of unreachable code; /admin redirects off-site.) -->
--<!-- ── Floating Global Chat Widget (visible on non-chat pages) ── -->
++<!-- ── Floating Global Chat Widget (visible on non-chat pages) ──
++ The chat UI is a lazy feature (features.json → chatUI), so the handlers below are stubs
++ until it has loaded: the first click fetches it and then does the thing. -->
<div id="floating-chat-widget" class="floating-chat-widget" style="display:none">
<div class="fcw-header" onmousedown="fcwStartDrag(event)">
<span><i class="fa-solid fa-comments"></i> Global Chat</span>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex b74f4a30..88b93293 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -966,7 +966,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
// Offline: join the streamer's PERSISTENT chat room (not global) so
// viewers can keep chatting + see history while the streamer is offline.
-- initChat(null, ch.user_id);
++ withChatUi(() => initChat(null, ch.user_id));
// Hide stream tabs on offline channels
const tabsC = document.getElementById('live-stream-tabs');
@@@ -1924,7 +1924,7 @@ function activateChannelStream(stream) {
console.error('[Player] init failed', e);
}
}
-- if (typeof initChat === 'function') initChat(stream.id, stream.user_id || _activeChannelUserId);
++ withChatUi(() => initChat(stream.id, stream.user_id || _activeChannelUserId));
if (typeof startCoinHeartbeat === 'function') startCoinHeartbeat(stream.id);
if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(stream.user_id);
startUptime(stream.started_at);
@@@ -3814,7 +3814,7 @@ async function openStream(streamId) {
document.getElementById('follower-count').textContent = `${s.follower_count || 0} followers`;
if (typeof initPlayer === 'function') initPlayer(s);
-- if (typeof initChat === 'function') initChat(streamId, s.user_id || _activeChannelUserId);
++ withChatUi(() => initChat(streamId, s.user_id || _activeChannelUserId));
if (typeof startCoinHeartbeat === 'function') startCoinHeartbeat(streamId);
if (typeof updateChannelPointsNav === 'function') updateChannelPointsNav(s.user_id);
loadStreamGoals(streamId);
ddiff --git a/public/js/app-chatpage.js b/public/js/app-chatpage.js
iindex 03756d4d..dd69c14b 100644
---- a/public/js/app-chatpage.js
++++ b/public/js/app-chatpage.js
@@@ -8,7 +8,7 @@
function loadChatPage() {
// Connect to global chat (no streamId). initChat loads the history itself — this page used to
// load and render all 500 rows a second time right after it.
-- initChat(null);
++ withChatUi(() => initChat(null));
// Global chat AI overview + timeline (refreshed periodically)
loadGlobalChatAi();
if (window._globalAiPollTimer) clearInterval(window._globalAiPollTimer);
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 868d207b..11bbe0f6 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -87,13 +87,25 @@ function ovUserTag(u, label) {
+ ` onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();ovUserMenu(event);}">${esc(name)}</span>`;
}
--/** Open the chat user menu; if chat has not loaded, fall back to the channel. */
++/**
++ * Open the chat user menu.
++ *
++ * The chat UI is a lazy feature (public/features.json → chatUI) and this page has no chat of its
++ * own, so the menu is not here yet: fetch the chat UI and open the same menu. If it cannot be
++ * loaded, fall back to the channel — the name is a link to it.
++ */
function ovUserMenu(event) {
if (typeof showChatContextMenu === 'function') return showChatContextMenu(event);
const el = event.currentTarget;
const core = el && el.dataset && el.dataset.coreUsername;
if (!core) return;
event.preventDefault();
++ if (window.ov && typeof window.ov.load === 'function') {
++ window.ov.load('chatUI').then(() => {
++ if (typeof showChatContextMenu === 'function') showChatContextMenu(event);
++ }).catch(() => { navigate(`/@${core}`); });
++ return;
++ }
navigate(`/@${core}`);
}
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 5303eef9..f597befa 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -851,7 +851,7 @@ function ensureBroadcastChat(streamId) {
if (sidebar) sidebar.style.display = '';
// The broadcast page is the signed-in streamer's own channel: say so, or chat takes it for someone else's and
// never reads the streamer's own messages aloud (chat.js _isOwnChannelChat).
-- if (typeof initChat === 'function' && streamId) initChat(streamId, (typeof currentUser !== 'undefined' && currentUser && currentUser.id) || null);
++ if (streamId) withChatUi(() => initChat(streamId, (typeof currentUser !== 'undefined' && currentUser && currentUser.id) || null));
}
/* ── Sign-in gate ────────────────────────────────────────────── */
ddiff --git a/public/js/chat-core.js b/public/js/chat-core.js
nnew file mode 100644
iindex 00000000..20c99792
---- /dev/null
++++ b/public/js/chat-core.js
@@@ -0,0 +1,377 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Live — Chat core: the part of chat that every page needs
++ ═══════════════════════════════════════════════════════════════
++ public/js/chat.js is the chat UI — the socket, the messages, the panels, the moderation. It
++ belongs where chat is (a channel, the chat page, the broadcast desk) and nowhere else, so it is
++ a lazy feature (public/features.json → chatUI): a page without chat does not download it.
++
++ This file is the part every page does need, and nothing more:
++ - toast(): the one-line wrapper every script on the site calls;
++ - the chat settings (this browser's copy and the server's), because the floating chat button
++ reads them and the server copy is synced on every page;
++ - the floating chat button's visibility: the button sits in index.html on every page, and it
++ has to appear on the pages that have no chat of their own;
++ - the voice-call invite deep link (/?vcInvite=…), which a notification can land on any page.
++
++ Loaded before chat.js. Both are plain scripts, so they share one global scope: chat.js reads
++ chatSettings and _fcwOpen from here, and this file calls back into chat.js only by name, and
++ only when the function it needs is there.
++ ═══════════════════════════════════════════════════════════════ */
++
++/** Run now if the document is already parsed, otherwise when it is. */
++function _onReady(fn) {
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });
++ else fn();
++}
++
++function toast(message, type = 'info') {
++ // Delegate to the app-level toast saved in app.js (loaded before this file).
++ // Do NOT use window.toast here — this file's own function declaration overwrites
++ // window.toast, so that check is always self-referential and causes infinite recursion.
++ if (typeof window._appToast === 'function') {
++ return window._appToast(message, type);
++ }
++ if (typeof console !== 'undefined' && console.log) {
++ console.log(`[Chat toast ${type}] ${String(message)}`);
++ }
++}
++
++// ── Chat settings (persisted to localStorage) ────────────────
++const CHAT_SETTINGS_KEY = 'openvibe_chat_settings';
++const CHAT_SETTINGS_DEFAULTS = {
++ // Appearance
++ showTimestamps: false,
++ timestampFormat: '12h', // '12h' or '24h'
++ fontSize: 'default', // 'small', 'default', 'large'
++ showAvatars: true,
++ showBadges: true,
++ readableColors: false, // Force minimum contrast on name colors
++ alternateBackground: false, // Zebra-stripe messages
++ // Emotes
++ animatedEmotes: true, // Show animated/GIF emotes
++ emoteScale: 'default', // 'small', 'default', 'large'
++ // Behavior
++ showDeletedMessages: false, // Show <deleted> instead of hiding
++ showSystemMessages: true, // Join/part/system messages
++ mentionHighlight: true, // Highlight messages containing your @name
++ autoScroll: true, // Auto-scroll to bottom on new messages
++ autoDeleteMinutes: 0, // Auto-delete your own new messages after N minutes (0 = off)
++ // Notifications
++ flashOnMention: true, // Flash browser tab on @mention
++ soundOnMention: false, // Play a sound on @mention
++ autoDeclineCalls: false, // Auto-decline incoming voice calls
++ // Widget
++ showFloatingChat: true, // Show floating global chat button on non-chat pages
++ fullscreenOverlayChat: true, // Show OBS-style chat overlay in fullscreen live player
++ compactMode: false, // Compact message layout (popout & popout-compatible surfaces)
++ // Cross-feed — show messages from other sources while in a stream chat
++ showGlobalInStream: true, // Show global chat messages in stream chat (on by default)
++ showAllStreamsInStream: true, // Show messages from ALL live streams in stream chat (on by default)
++ showOtherSlotsInStream: true, // Show chat from this streamer's OTHER live slots (on by default)
++ // TTS
++ ttsEnabled: true, // TTS participation toggle. ON = your messages are read normally;
++ // OFF prepends "." to your messages so TTS skips them (and mutes local playback).
++ // Defaults ON so messages aren't dot-prefixed/muted-styled by default.
++ streamingTtsEnabled: true, // Separate TTS toggle while broadcasting live
++ channelTtsAlways: false, // Hear your own channel's chat TTS even while NOT live
++ ttsHearWatching: false, // Also read TTS aloud in this browser on channels you WATCH. Off by default: the stream's
++ // own audio already carries its chat TTS (the streamer plays it), and a second copy in
++ // every viewer's browser was read over the stream (owner and Maticus, 2026-10-26).
++ // This browser only: not part of the synced chat.tts_defaults record.
++ ttsVolume: 80, // TTS message volume (0–100)
++ soundVolume: 80, // Chat !sound / soundboard clip volume (0–100), independent of TTS
++ // Chat sounds (soundboard + channel !sound commands) — independent of TTS
++ soundsEnabled: true, // Play chat sound clips (default on)
++ // Per-source TTS — which relay platforms contribute to TTS
++ ttsSrcNative: true, // Native OpenVibe.Live chat
++ ttsSrcRS: true, // RobotStreamer relay
++ ttsSrcKick: true, // Kick relay
++ ttsSrcYoutube: true, // YouTube relay
++ ttsSrcTwitch: true, // Twitch relay
++ // Friendly global chat: null = not chosen yet (on for a viewer's first day), true/false = chosen.
++ friendlyGlobal: null,
++};
++let chatSettings = { ...CHAT_SETTINGS_DEFAULTS };
++
++const CHAT_SELF_DELETE_MINUTES = 3;
++
++function normalizeChatAutoDeleteMinutes(value) {
++ const minutes = parseInt(value, 10);
++ if (!Number.isFinite(minutes) || minutes <= 0) return 0;
++ return Math.max(CHAT_SELF_DELETE_MINUTES, minutes);
++}
++
++let _settingsSyncTimer = null;
++
++function loadChatSettings() {
++ try {
++ const saved = JSON.parse(localStorage.getItem(CHAT_SETTINGS_KEY));
++ if (saved) {
++ chatSettings = { ...CHAT_SETTINGS_DEFAULTS, ...saved };
++ // Migration: the TTS toggle changed from a receiver-side mute to a
++ // sender-side "prefix '.' to skip TTS" control that defaults ON. Old
++ // saved settings may carry ttsEnabled:false from the previous default,
++ // which would dot-prefix/mute every message — reset it once.
++ if (!saved.ttsSendModeV2) {
++ chatSettings.ttsEnabled = true;
++ chatSettings.ttsSendModeV2 = true;
++ try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { /* */ }
++ }
++ }
++ } catch { /* use defaults */ }
++ chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);
++ // Async server sync — merge server settings on top of local
++ _syncSettingsFromServer();
++}
++function saveChatSettings() {
++ chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);
++ try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }
++ _applyChatUi();
++ // Debounced push to server
++ _debounceSyncSettingsToServer();
++}
++
++/**
++ * The chat UI paints itself from the settings. It is not on every page (it is a lazy feature),
++ * so it is asked for by name — a page without chat simply has nothing to paint.
++ */
++function _applyChatUi() {
++ if (typeof applyChatSettings === 'function') applyChatSettings();
++ if (typeof syncSettingsPanelUI === 'function') syncSettingsPanelUI();
++ if (typeof syncTTSToggleButtons === 'function') syncTTSToggleButtons();
++}
++
++/**
++ * Run `fn` with the chat UI in place.
++ *
++ * The chat UI (public/js/chat.js — the socket, the messages, the panels) is a lazy feature
++ * (public/features.json → chatUI): only the pages that have chat ask for it. A page that has
++ * chat but has not loaded it yet — the route's scripts and the chat's arrive side by side — asks
++ * for it here first, so the socket is never missing when the page talks to it.
++ */
++function withChatUi(fn) {
++ if (typeof initChat !== 'function' && window.ov && typeof window.ov.load === 'function') {
++ window.ov.load('chatUI').then(() => { try { fn(); } catch (e) { console.warn('[chat]', e); } }, () => { });
++ return;
++ }
++ try { fn(); } catch (e) { console.warn('[chat]', e); }
++}
++
++// Portable chat preferences live in OpenVibe.Network (user modules chat.preferences and chat.tts_defaults),
++// read and written through OpenVibe.Chat at /api/chat/preferences and /api/chat/tts-settings. Only the
++// settings mapped below have fields there; the rest of chatSettings stays in Live's /api/auth/preferences. A patch value of null removes the
++// field, i.e. "Live's default"; Chat writes nothing when nothing changed.
++const CHAT_PREFS_FONT_SCALE = { small: 0.88, large: 1.18 };
++function _chatPrefsFromSettings(s) {
++ return {
++ timestamps: s.showTimestamps ? true : null,
++ compact: s.compactMode ? true : null,
++ font_scale: CHAT_PREFS_FONT_SCALE[s.fontSize] || null,
++ show_badges: s.showBadges === false ? false : null,
++ };
++}
++function _chatSettingsFromPrefs(p) {
++ const out = {
++ showTimestamps: p.timestamps === true,
++ compactMode: p.compact === true,
++ fontSize: 'default',
++ showBadges: p.show_badges !== false,
++ };
++ if (typeof p.font_scale === 'number') out.fontSize = p.font_scale < 0.95 ? 'small' : p.font_scale > 1.05 ? 'large' : 'default';
++ return out;
++}
++// Text-to-speech and sounds: the user module chat.tts_defaults (v2), through /api/chat/tts-settings.
++// A field at Live's default is sent as null (removed), so the record only holds what the person changed.
++const TTS_MODULE_SOURCES = { native: 'ttsSrcNative', robotstreamer: 'ttsSrcRS', kick: 'ttsSrcKick', youtube: 'ttsSrcYoutube', twitch: 'ttsSrcTwitch' };
++function _ttsModuleFromSettings(s) {
++ const sources = {};
++ for (const [k, key] of Object.entries(TTS_MODULE_SOURCES)) if (s[key] === false) sources[k] = false;
++ const pct = (v) => (Number.isInteger(v) && v >= 0 && v <= 100 && v !== 80 ? v : null);
++ return {
++ send: s.ttsEnabled === false ? false : null,
++ send_while_live: s.streamingTtsEnabled === false ? false : null,
++ volume: pct(s.ttsVolume),
++ sounds: s.soundsEnabled === false ? false : null,
++ sound_volume: pct(s.soundVolume),
++ sources: Object.keys(sources).length ? sources : null,
++ };
++}
++function _settingsFromTtsModule(t) {
++ const out = {
++ ttsEnabled: t.send !== false,
++ streamingTtsEnabled: t.send_while_live !== false,
++ ttsVolume: Number.isInteger(t.volume) ? t.volume : 80,
++ soundsEnabled: t.sounds !== false,
++ soundVolume: Number.isInteger(t.sound_volume) ? t.sound_volume : 80,
++ };
++ const src = t.sources && typeof t.sources === 'object' ? t.sources : {};
++ for (const [k, key] of Object.entries(TTS_MODULE_SOURCES)) out[key] = src[k] !== false;
++ return out;
++}
++const CHAT_MODULES = [
++ { url: '/api/chat/preferences', key: 'preferences', from: _chatPrefsFromSettings, to: _chatSettingsFromPrefs },
++ { url: '/api/chat/tts-settings', key: 'settings', from: _ttsModuleFromSettings, to: _settingsFromTtsModule },
++];
++function _pushChatPrefs(token) {
++ return Promise.all(CHAT_MODULES.map((m) => fetch(m.url, {
++ method: 'PUT',
++ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
++ body: JSON.stringify({ [m.key]: m.from(chatSettings) }),
++ }).catch(() => { /* Chat or Network unavailable: Live's copy still has them */ })));
++}
++/** After Live's copy: each Network record wins for its fields; none yet → seed it from this browser. */
++function _syncChatPrefsFromChat(token) {
++ for (const m of CHAT_MODULES) {
++ fetch(m.url, { headers: { 'Authorization': `Bearer ${token}` } })
++ .then(r => r.ok ? r.json() : null)
++ .then(data => {
++ const rec = data && data[m.key];
++ if (!rec || typeof rec !== 'object') return;
++ if (!data.revision) {
++ fetch(m.url, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ [m.key]: m.from(chatSettings) }) }).catch(() => {});
++ return;
++ }
++ chatSettings = { ...chatSettings, ...m.to(rec) };
++ try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }
++ _applyChatUi();
++ })
++ .catch(() => { /* offline — use local */ });
++ }
++}
++
++function _syncSettingsFromServer() {
++ const token = localStorage.getItem('token');
++ if (!token) return;
++ fetch('/api/auth/preferences', { headers: { 'Authorization': `Bearer ${token}` } })
++ .then(r => r.ok ? r.json() : null)
++ .then(data => {
++ if (data?.chatSettings && typeof data.chatSettings === 'object' && Object.keys(data.chatSettings).length > 0) {
++ // Server wins on conflicts
++ chatSettings = { ...CHAT_SETTINGS_DEFAULTS, ...chatSettings, ...data.chatSettings };
++ chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);
++ try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }
++ _applyChatUi();
++ }
++ })
++ .catch(() => { /* offline — use local */ })
++ .finally(() => _syncChatPrefsFromChat(token));
++}
++
++function _debounceSyncSettingsToServer() {
++ if (_settingsSyncTimer) clearTimeout(_settingsSyncTimer);
++ _settingsSyncTimer = setTimeout(() => {
++ const token = localStorage.getItem('token');
++ if (!token) return;
++ fetch('/api/auth/preferences', {
++ method: 'PUT',
++ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
++ body: JSON.stringify({ chatSettings })
++ }).catch(() => { /* silent fail */ });
++ _pushChatPrefs(token);
++ }, 500);
++}
++
++// ── Floating chat widget state ───────────────────────────────
++let _fcwOpen = false;
++
++/** Show/hide the floating chat FAB on non-chat pages */
++function _fcwUpdateVisibility() {
++ const fab = document.getElementById('floating-chat-fab');
++ const widget = document.getElementById('floating-chat-widget');
++ const disabled = chatSettings && chatSettings.showFloatingChat === false;
++
++ // Hide on the global chat page (already has full chat)
++ const chatPage = document.getElementById('page-chat');
++ const isOnChatPage = chatPage && chatPage.classList.contains('active');
++
++ // Hide on live stream / channel pages (already have chat sidebar)
++ const streamPage = document.getElementById('page-stream');
++ const channelPage = document.getElementById('page-channel');
++ const isOnStreamPage = (streamPage && streamPage.classList.contains('active'))
++ || (channelPage && channelPage.classList.contains('active'));
++
++ // Hide on broadcast page (has its own chat panel)
++ const broadcastPage = document.getElementById('page-broadcast');
++ const isOnBroadcastPage = broadcastPage && broadcastPage.classList.contains('active');
++
++ if (isOnChatPage || isOnStreamPage || isOnBroadcastPage || disabled) {
++ if (fab) fab.style.display = 'none';
++ if (widget) widget.style.display = 'none';
++ _fcwOpen = false;
++ } else {
++ // Not on chat/stream page — show FAB
++ if (fab) fab.style.display = '';
++ if (!_fcwOpen && widget) widget.style.display = 'none';
++ }
++}
++
++// The floating chat button follows the page: it shows on the pages that have no chat of their own.
++// (The router announces page changes; this replaces one MutationObserver per page section.)
++_onReady(() => {
++ document.addEventListener('ov:page', _fcwUpdateVisibility);
++ // Initial check
++ setTimeout(_fcwUpdateVisibility, 100);
++});
++
++async function _joinVoiceChannelFromInvite(channelId, channelName, switchToChat = false) {
++ if (!channelId) return;
++
++ if (switchToChat && typeof showPage === 'function') {
++ showPage('chat');
++ }
++
++ // Voice code loads on demand (features.json → voice); an invite is reason enough to fetch it.
++ if (typeof vcJoinChannel !== 'function' && window.ov) {
++ try { await ov.load('voice'); } catch { /* falls through to the pending-invite path */ }
++ }
++ if (typeof vcFetchChannels !== 'function' || typeof vcJoinChannel !== 'function' || typeof vcState === 'undefined') {
++ window._pendingVcInvite = { channelId, channelName, at: Date.now() };
++ toast(`Invite received for ${channelName || 'Voice Channel'}. Open Chat to join.`, 'info');
++ return;
++ }
++
++ await vcFetchChannels();
++ const ch = (vcState.channels || []).find(c => c.id === channelId);
++ if (!ch) {
++ toast('That call channel is no longer available', 'error');
++ return;
++ }
++ if (typeof callState !== 'undefined' && (callState.joined || callState.connecting) && callState.channelId === ch.id) {
++ return;
++ }
++ // Through the same door as a click: leaves whatever channel we are in first.
++ vcSelectChannel(ch.id);
++}
++
++async function acceptVcInvite(channelId, channelName) {
++ try {
++ // The incoming-call overlay belongs to the chat UI; on a page without chat there is none.
++ if (typeof window._dismissIncomingVcCallOverlay === 'function') window._dismissIncomingVcCallOverlay();
++ await _joinVoiceChannelFromInvite(channelId, channelName || 'Voice Channel', true);
++ } catch (err) {
++ toast(err.message || 'Failed to join call invite', 'error');
++ }
++}
++
++/**
++ * Deep-link support from notifications: /?vcInvite=<channelId>.
++ * The invite lands on whatever page the person was on, so the join happens here rather than in
++ * the chat UI. A pending invite (the voice feature was not there yet) waits for the chat page.
++ */
++_onReady(() => {
++ try {
++ const inviteChannel = new URLSearchParams(window.location.search).get('vcInvite');
++ if (inviteChannel) {
++ setTimeout(() => {
++ acceptVcInvite(inviteChannel, 'Voice Channel');
++ }, 450);
++ }
++ if (window._pendingVcInvite?.channelId && typeof vcFetchChannels === 'function' && typeof vcJoinChannel === 'function') {
++ const pending = window._pendingVcInvite;
++ window._pendingVcInvite = null;
++ setTimeout(() => {
++ acceptVcInvite(pending.channelId, pending.channelName || 'Voice Channel');
++ }, 450);
++ }
++ } catch { }
++});
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex e2983f9b..e6795f97 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -11,23 +11,12 @@ let chatRenderTargetId = null;
// Spoilers masked in plain text (reply snippets, TTS): chat-spoilers.js
const _spoilFree = (t) => (window.OpenVibeSpoilers ? OpenVibeSpoilers.mask(t) : t);
--function toast(message, type = 'info') {
-- // Delegate to the app-level toast saved in app.js (loaded before this file).
-- // Do NOT use window.toast here — chat.js's own function declaration overwrites
-- // window.toast, so that check is always self-referential and causes infinite recursion.
-- if (typeof window._appToast === 'function') {
-- return window._appToast(message, type);
-- }
-- if (typeof console !== 'undefined' && console.log) {
-- console.log(`[Chat toast ${type}] ${String(message)}`);
-- }
--}
++// ── toast() moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
// ── Chat mode (Global / Voice Call) ──────────────────────────
let chatMode = 'global'; // 'global' or 'voice'
--// ── Floating chat widget state ───────────────────────────────
--let _fcwOpen = false;
++// ── the floating chat button state moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
let _fcwUnread = 0;
let _fcwDragging = false;
let _fcwDragOffset = { x: 0, y: 0 };
@@@ -98,64 +87,9 @@ function relayBadgeHTML(platform) {
return `<span class="chat-badge chat-relay-badge" data-tip="${b.label}" style="--relay-color:${b.color}"><i class="${b.icon}"></i></span>`;
}
--// ── Chat settings (persisted to localStorage) ────────────────
--const CHAT_SETTINGS_KEY = 'openvibe_chat_settings';
--const CHAT_SETTINGS_DEFAULTS = {
-- // Appearance
-- showTimestamps: false,
-- timestampFormat: '12h', // '12h' or '24h'
-- fontSize: 'default', // 'small', 'default', 'large'
-- showAvatars: true,
-- showBadges: true,
-- readableColors: false, // Force minimum contrast on name colors
-- alternateBackground: false, // Zebra-stripe messages
-- // Emotes
-- animatedEmotes: true, // Show animated/GIF emotes
-- emoteScale: 'default', // 'small', 'default', 'large'
-- // Behavior
-- showDeletedMessages: false, // Show <deleted> instead of hiding
-- showSystemMessages: true, // Join/part/system messages
-- mentionHighlight: true, // Highlight messages containing your @name
-- autoScroll: true, // Auto-scroll to bottom on new messages
-- autoDeleteMinutes: 0, // Auto-delete your own new messages after N minutes (0 = off)
-- // Notifications
-- flashOnMention: true, // Flash browser tab on @mention
-- soundOnMention: false, // Play a sound on @mention
-- autoDeclineCalls: false, // Auto-decline incoming voice calls
-- // Widget
-- showFloatingChat: true, // Show floating global chat button on non-chat pages
-- fullscreenOverlayChat: true, // Show OBS-style chat overlay in fullscreen live player
-- compactMode: false, // Compact message layout (popout & popout-compatible surfaces)
-- // Cross-feed — show messages from other sources while in a stream chat
-- showGlobalInStream: true, // Show global chat messages in stream chat (on by default)
-- showAllStreamsInStream: true, // Show messages from ALL live streams in stream chat (on by default)
-- showOtherSlotsInStream: true, // Show chat from this streamer's OTHER live slots (on by default)
-- // TTS
-- ttsEnabled: true, // TTS participation toggle. ON = your messages are read normally;
-- // OFF prepends "." to your messages so TTS skips them (and mutes local playback).
-- // Defaults ON so messages aren't dot-prefixed/muted-styled by default.
-- streamingTtsEnabled: true, // Separate TTS toggle while broadcasting live
-- channelTtsAlways: false, // Hear your own channel's chat TTS even while NOT live
-- ttsHearWatching: false, // Also read TTS aloud in this browser on channels you WATCH. Off by default: the stream's
-- // own audio already carries its chat TTS (the streamer plays it), and a second copy in
-- // every viewer's browser was read over the stream (owner and Maticus, 2026-10-26).
-- // This browser only: not part of the synced chat.tts_defaults record.
-- ttsVolume: 80, // TTS message volume (0–100)
-- soundVolume: 80, // Chat !sound / soundboard clip volume (0–100), independent of TTS
-- // Chat sounds (soundboard + channel !sound commands) — independent of TTS
-- soundsEnabled: true, // Play chat sound clips (default on)
-- // Per-source TTS — which relay platforms contribute to TTS
-- ttsSrcNative: true, // Native OpenVibe.Live chat
-- ttsSrcRS: true, // RobotStreamer relay
-- ttsSrcKick: true, // Kick relay
-- ttsSrcYoutube: true, // YouTube relay
-- ttsSrcTwitch: true, // Twitch relay
-- // Friendly global chat: null = not chosen yet (on for a viewer's first day), true/false = chosen.
-- friendlyGlobal: null,
--};
--let chatSettings = { ...CHAT_SETTINGS_DEFAULTS };
++// ── the chat settings moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
let chatSettingsPanelOpen = false;
--const CHAT_SELF_DELETE_MINUTES = 3;
++// ── CHAT_SELF_DELETE_MINUTES moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
let chatSelfDeletePolicy = {
allowAutoDelete: true,
allowDeleteAll: true,
@@@ -193,11 +127,7 @@ const MAIN_CHAT_MAX_MESSAGES = 500;
let _fullscreenChatIdleTimer = null;
let _fullscreenChatRecent = [];
--function normalizeChatAutoDeleteMinutes(value) {
-- const minutes = parseInt(value, 10);
-- if (!Number.isFinite(minutes) || minutes <= 0) return 0;
-- return Math.max(CHAT_SELF_DELETE_MINUTES, minutes);
--}
++// ── normalizeChatAutoDeleteMinutes() moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
function canUseViewerAutoDeleteHere() {
return !chatStreamId || !!chatSelfDeletePolicy.allowAutoDelete;
@@@ -207,153 +137,9 @@ function canUseViewerDeleteAllHere() {
return !chatStreamId || !!chatSelfDeletePolicy.allowDeleteAll;
}
--let _settingsSyncTimer = null;
--
--function loadChatSettings() {
-- try {
-- const saved = JSON.parse(localStorage.getItem(CHAT_SETTINGS_KEY));
-- if (saved) {
-- chatSettings = { ...CHAT_SETTINGS_DEFAULTS, ...saved };
-- // Migration: the TTS toggle changed from a receiver-side mute to a
-- // sender-side "prefix '.' to skip TTS" control that defaults ON. Old
-- // saved settings may carry ttsEnabled:false from the previous default,
-- // which would dot-prefix/mute every message — reset it once.
-- if (!saved.ttsSendModeV2) {
-- chatSettings.ttsEnabled = true;
-- chatSettings.ttsSendModeV2 = true;
-- try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { /* */ }
-- }
-- }
-- } catch { /* use defaults */ }
-- chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);
-- // Async server sync — merge server settings on top of local
-- _syncSettingsFromServer();
--}
--function saveChatSettings() {
-- chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);
-- try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }
-- applyChatSettings();
-- // Debounced push to server
-- _debounceSyncSettingsToServer();
--}
++// ── the settings sync timer moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
--// Portable chat preferences live in OpenVibe.Network (user modules chat.preferences and chat.tts_defaults),
--// read and written through OpenVibe.Chat at /api/chat/preferences and /api/chat/tts-settings. Only the
--// settings mapped below have fields there; the rest of chatSettings stays in Live's /api/auth/preferences. A patch value of null removes the
--// field, i.e. "Live's default"; Chat writes nothing when nothing changed.
--const CHAT_PREFS_FONT_SCALE = { small: 0.88, large: 1.18 };
--function _chatPrefsFromSettings(s) {
-- return {
-- timestamps: s.showTimestamps ? true : null,
-- compact: s.compactMode ? true : null,
-- font_scale: CHAT_PREFS_FONT_SCALE[s.fontSize] || null,
-- show_badges: s.showBadges === false ? false : null,
-- };
--}
--function _chatSettingsFromPrefs(p) {
-- const out = {
-- showTimestamps: p.timestamps === true,
-- compactMode: p.compact === true,
-- fontSize: 'default',
-- showBadges: p.show_badges !== false,
-- };
-- if (typeof p.font_scale === 'number') out.fontSize = p.font_scale < 0.95 ? 'small' : p.font_scale > 1.05 ? 'large' : 'default';
-- return out;
--}
--// Text-to-speech and sounds: the user module chat.tts_defaults (v2), through /api/chat/tts-settings.
--// A field at Live's default is sent as null (removed), so the record only holds what the person changed.
--const TTS_MODULE_SOURCES = { native: 'ttsSrcNative', robotstreamer: 'ttsSrcRS', kick: 'ttsSrcKick', youtube: 'ttsSrcYoutube', twitch: 'ttsSrcTwitch' };
--function _ttsModuleFromSettings(s) {
-- const sources = {};
-- for (const [k, key] of Object.entries(TTS_MODULE_SOURCES)) if (s[key] === false) sources[k] = false;
-- const pct = (v) => (Number.isInteger(v) && v >= 0 && v <= 100 && v !== 80 ? v : null);
-- return {
-- send: s.ttsEnabled === false ? false : null,
-- send_while_live: s.streamingTtsEnabled === false ? false : null,
-- volume: pct(s.ttsVolume),
-- sounds: s.soundsEnabled === false ? false : null,
-- sound_volume: pct(s.soundVolume),
-- sources: Object.keys(sources).length ? sources : null,
-- };
--}
--function _settingsFromTtsModule(t) {
-- const out = {
-- ttsEnabled: t.send !== false,
-- streamingTtsEnabled: t.send_while_live !== false,
-- ttsVolume: Number.isInteger(t.volume) ? t.volume : 80,
-- soundsEnabled: t.sounds !== false,
-- soundVolume: Number.isInteger(t.sound_volume) ? t.sound_volume : 80,
-- };
-- const src = t.sources && typeof t.sources === 'object' ? t.sources : {};
-- for (const [k, key] of Object.entries(TTS_MODULE_SOURCES)) out[key] = src[k] !== false;
-- return out;
--}
--const CHAT_MODULES = [
-- { url: '/api/chat/preferences', key: 'preferences', from: _chatPrefsFromSettings, to: _chatSettingsFromPrefs },
-- { url: '/api/chat/tts-settings', key: 'settings', from: _ttsModuleFromSettings, to: _settingsFromTtsModule },
--];
--function _pushChatPrefs(token) {
-- return Promise.all(CHAT_MODULES.map((m) => fetch(m.url, {
-- method: 'PUT',
-- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
-- body: JSON.stringify({ [m.key]: m.from(chatSettings) }),
-- }).catch(() => { /* Chat or Network unavailable: Live's copy still has them */ })));
--}
--/** After Live's copy: each Network record wins for its fields; none yet → seed it from this browser. */
--function _syncChatPrefsFromChat(token) {
-- for (const m of CHAT_MODULES) {
-- fetch(m.url, { headers: { 'Authorization': `Bearer ${token}` } })
-- .then(r => r.ok ? r.json() : null)
-- .then(data => {
-- const rec = data && data[m.key];
-- if (!rec || typeof rec !== 'object') return;
-- if (!data.revision) {
-- fetch(m.url, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ [m.key]: m.from(chatSettings) }) }).catch(() => {});
-- return;
-- }
-- chatSettings = { ...chatSettings, ...m.to(rec) };
-- try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }
-- applyChatSettings();
-- syncSettingsPanelUI();
-- if (typeof syncTTSToggleButtons === 'function') syncTTSToggleButtons();
-- })
-- .catch(() => { /* offline — use local */ });
-- }
--}
--
--function _syncSettingsFromServer() {
-- const token = localStorage.getItem('token');
-- if (!token) return;
-- fetch('/api/auth/preferences', { headers: { 'Authorization': `Bearer ${token}` } })
-- .then(r => r.ok ? r.json() : null)
-- .then(data => {
-- if (data?.chatSettings && typeof data.chatSettings === 'object' && Object.keys(data.chatSettings).length > 0) {
-- // Server wins on conflicts
-- chatSettings = { ...CHAT_SETTINGS_DEFAULTS, ...chatSettings, ...data.chatSettings };
-- chatSettings.autoDeleteMinutes = normalizeChatAutoDeleteMinutes(chatSettings.autoDeleteMinutes);
-- try { localStorage.setItem(CHAT_SETTINGS_KEY, JSON.stringify(chatSettings)); } catch { }
-- applyChatSettings();
-- syncSettingsPanelUI();
-- syncTTSToggleButtons();
-- }
-- })
-- .catch(() => { /* offline — use local */ })
-- .finally(() => _syncChatPrefsFromChat(token));
--}
--
--function _debounceSyncSettingsToServer() {
-- if (_settingsSyncTimer) clearTimeout(_settingsSyncTimer);
-- _settingsSyncTimer = setTimeout(() => {
-- const token = localStorage.getItem('token');
-- if (!token) return;
-- fetch('/api/auth/preferences', {
-- method: 'PUT',
-- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
-- body: JSON.stringify({ chatSettings })
-- }).catch(() => { /* silent fail */ });
-- _pushChatPrefs(token);
-- }, 500);
--}
++// ── the settings functions moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
function _vcEnsureAudioContext() {
const Ctx = window.AudioContext || window.webkitAudioContext;
@@@ -4275,44 +4061,7 @@ async function ctxCallUser(username, userId) {
}
}
--async function _joinVoiceChannelFromInvite(channelId, channelName, switchToChat = false) {
-- if (!channelId) return;
--
-- if (switchToChat && typeof showPage === 'function') {
-- showPage('chat');
-- }
--
-- // Voice code loads on demand (features.json → voice); an invite is reason enough to fetch it.
-- if (typeof vcJoinChannel !== 'function' && window.ov) {
-- try { await ov.load('voice'); } catch { /* falls through to the pending-invite path */ }
-- }
-- if (typeof vcFetchChannels !== 'function' || typeof vcJoinChannel !== 'function' || typeof vcState === 'undefined') {
-- window._pendingVcInvite = { channelId, channelName, at: Date.now() };
-- toast(`Invite received for ${channelName || 'Voice Channel'}. Open Chat to join.`, 'info');
-- return;
-- }
--
-- await vcFetchChannels();
-- const ch = (vcState.channels || []).find(c => c.id === channelId);
-- if (!ch) {
-- toast('That call channel is no longer available', 'error');
-- return;
-- }
-- if (typeof callState !== 'undefined' && (callState.joined || callState.connecting) && callState.channelId === ch.id) {
-- return;
-- }
-- // Through the same door as a click: leaves whatever channel we are in first.
-- vcSelectChannel(ch.id);
--}
--
--async function acceptVcInvite(channelId, channelName) {
-- try {
-- _dismissIncomingVcCallOverlay();
-- await _joinVoiceChannelFromInvite(channelId, channelName || 'Voice Channel', true);
-- } catch (err) {
-- toast(err.message || 'Failed to join call invite', 'error');
-- }
--}
++// ── the voice-call invite join moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
function ctxViewChannel(username) {
dismissContextMenu();
@@@ -6404,36 +6153,7 @@ function updateChatModeVoiceOption(enabled) {
FLOATING CHAT WIDGET
═══════════════════════════════════════════════════════════════ */
--/** Show/hide the floating chat FAB on non-chat pages */
--function _fcwUpdateVisibility() {
-- const fab = document.getElementById('floating-chat-fab');
-- const widget = document.getElementById('floating-chat-widget');
-- const disabled = chatSettings && chatSettings.showFloatingChat === false;
--
-- // Hide on the global chat page (already has full chat)
-- const chatPage = document.getElementById('page-chat');
-- const isOnChatPage = chatPage && chatPage.classList.contains('active');
--
-- // Hide on live stream / channel pages (already have chat sidebar)
-- const streamPage = document.getElementById('page-stream');
-- const channelPage = document.getElementById('page-channel');
-- const isOnStreamPage = (streamPage && streamPage.classList.contains('active'))
-- || (channelPage && channelPage.classList.contains('active'));
--
-- // Hide on broadcast page (has its own chat panel)
-- const broadcastPage = document.getElementById('page-broadcast');
-- const isOnBroadcastPage = broadcastPage && broadcastPage.classList.contains('active');
--
-- if (isOnChatPage || isOnStreamPage || isOnBroadcastPage || disabled) {
-- if (fab) fab.style.display = 'none';
-- if (widget) widget.style.display = 'none';
-- _fcwOpen = false;
-- } else {
-- // Not on chat/stream page — show FAB
-- if (fab) fab.style.display = '';
-- if (!_fcwOpen && widget) widget.style.display = 'none';
-- }
--}
++// ── _fcwUpdateVisibility() moved to chat-core.js (2026-11-07): every page needs it, the chat UI does not.
function fcwToggle() {
_fcwOpen = !_fcwOpen;
@@@ -6668,37 +6388,16 @@ function _fcwStopResize() {
// Page markup that arrives later (ov-loader.js fragments) gets the same one-time setup the
// boot-time markup got: font-size classes and scroll containment, GIF buttons.
++// (The GIF pickers initialise when the chat extras load — features.json → chatExtras.after —
++// not here: calling them on every page fetched /api/chat/gif/providers on every page, the home
++// page included.)
document.addEventListener('ov:fragment', () => {
try { applyChatSettings(); } catch { /* */ }
if (typeof initGifPickers === 'function') { try { initGifPickers(); } catch { /* */ } }
});
--// Hook into page navigation to show/hide FAB
--document.addEventListener('DOMContentLoaded', () => {
-- // GIF pickers initialise when the chat extras load (features.json → chatExtras.after); calling
-- // it here fetched /api/chat/gif/providers on every page, the home page included.
-- // The router announces page changes; this replaces one MutationObserver per page section.
-- document.addEventListener('ov:page', _fcwUpdateVisibility);
-- // Initial check
-- setTimeout(_fcwUpdateVisibility, 100);
--
-- // Deep-link support from notifications: /?vcInvite=<channelId>
-- try {
-- const inviteChannel = new URLSearchParams(window.location.search).get('vcInvite');
-- if (inviteChannel) {
-- setTimeout(() => {
-- acceptVcInvite(inviteChannel, 'Voice Channel');
-- }, 450);
-- }
-- if (window._pendingVcInvite?.channelId && typeof vcFetchChannels === 'function' && typeof vcJoinChannel === 'function') {
-- const pending = window._pendingVcInvite;
-- window._pendingVcInvite = null;
-- setTimeout(() => {
-- acceptVcInvite(pending.channelId, pending.channelName || 'Voice Channel');
-- }, 450);
-- }
-- } catch { }
--});
++// The floating chat button's visibility and the ?vcInvite= deep link moved to chat-core.js
++// (2026-11-07): both belong to every page, not to the chat UI.
// ── Re-authenticate chat WebSocket on login/logout ───────────
// When the user logs in or registers, the navbar updates but the existing
ddiff --git a/public/popout-chat.html b/public/popout-chat.html
iindex 79c78334..a26eb8c2 100644
---- a/public/popout-chat.html
++++ b/public/popout-chat.html
@@@ -264,6 +264,8 @@
<!-- OpenVibeChat (user menus, linkify, TTS link text): chat.js needs it, as on the main page. Without it every
name clicked in the popout stayed on "Loading..." (OpenVibeChat is not defined). -->
<script src="/js/chat-shared.js"></script>
++ <!-- chat-core.js (the settings, toast, the floating button) comes before chat.js, as on the main page. -->
++ <script src="/js/chat-core.js"></script>
<script src="/js/chat.js"></script>
<script src="/js/chat-autocomplete.js"></script>
<script>
ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex f02d51c1..317ff0a1 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -19,17 +19,19 @@ const ROOT = path.join(__dirname, '..', '..');
// openvibe-shared/perf-budget names; "js" is the eager scripts (incl. route scripts for "/"),
// "css" the render-blocking stylesheets.
const BUDGETS = {
-- htmlRawKB: 137.7, // 137.7 with the network section on top of S7 (2026-11-07); 136.9 with the OpenVibe network section (2026-11-07: all 55 sites as real links with their lines, for crawlers; the old 14-card hub and the tab row went); 129.2 with the Agents live row on the home (2026-11-06, Agent Streams S2: one section and its manifest entry); 128.3 with the agent stream's route (2026-11-06: its placeholder in the HTML and its asset manifest entries, Live #622); 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
-- htmlBrotliKB: 30,
-- jsFiles: 26, // measured 23 (incl. route scripts for "/")
-- jsRawKB: 1310.5, // 1310.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 1308.1 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 1307.1 with the network section's script (2026-11-07: initHomeNetwork; the tab controller went); 1305.6 with chat's go-live and Rundown event cards (2026-11-07, Live #653: public/js/chat.js renderChatEventCard and its styles, ~9 KB); 1294.8 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 1293.3 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 1291.2 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 1280.1 (2026-11-03: the chat name card shows at once, the logs give up after ten seconds; headroom for openvibe-shared 4.107.3); 1279.2 (2026-11-03: openvibe-shared 4.107.0, open.<tld> brands; the home's reveal scroll listener hooked once); 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
-- jsBrotliKB: 306.5, // 306.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 306 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 305.7 with the network section's script (2026-11-07); 305.5 with chat's go-live and Rundown event cards (2026-11-07, Live #653); 303.1 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 302.9 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 302.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 302.1 (2026-11-06: TTS speaks emotes by name, ttsSpeakableEmotes in chat-shared.js, Live #626); 301.7 with the agent stream's route (2026-11-06: the route in app.js and its shape in ov-skeleton.js; the agent's own scripts stay route-loaded, Live #622); 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js, Live #617); 301.2 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 300.1 (2026-11-03: every api() read gives up after 20 s, Live #589; openvibe-shared 4.107.3); 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
++ htmlRawKB: 138.7, // 138.7 with the chatUI feature (2026-11-07: the asset manifest carries the registry, and the registry now names the chat UI and its stubs; the chat scripts and the chat stylesheet are gone from the home page) on openvibe-shared 4.133.0; 137.9 with chat-core.js (2026-11-07: one more script tag on the home page — the part of chat every page needs; the chat UI itself leaves in the next step of the same change); 137.7 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balance); htmlBrotliKB: 30,; 137.7 with the network section on top of S7 (2026-11-07); 136.9 with the OpenVibe network section (2026-11-07: all 55 sites as real links with their lines, for crawlers; the old 14-card hub and the tab row went); 129.2 with the Agents live row on the home (2026-11-06, Agent Streams S2: one section and its manifest entry); 128.3 with the agent stream's route (2026-11-06: its placeholder in the HTML and its asset manifest entries, Live #622); 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
++ jsFiles: 25, // 25 with the chat UI gone (2026-11-07: the home page no longer loads public/js/chat.js and public/js/chat-shared.js — the chat is a lazy feature now, and the routes that have chat ask for it) on openvibe-shared 4.133.0; 27 with chat-core.js (2026-11-07: one more file on the home page — the part of chat every page needs); measured 23 (incl. route scripts for "/")
++ jsRawKB: 955.2, // 955.2 with the chat UI gone (2026-11-07: the home page no longer downloads the chat — public/js/chat.js (326 KB) and public/js/chat-shared.js (35 KB) left the shell, public/js/chat-core.js (19 KB, the part of chat every page needs) stayed; measured 1314.2 before); 1314.2 with chat-core.js (2026-11-07: the part of chat every page needs — toast, the settings, the floating button, the call-invite link — moved out of chat.js into public/js/chat-core.js) on openvibe-shared 4.133.0 (1310.5); 1310.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 1308.1 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 1307.1 with the network section's script (2026-11-07: initHomeNetwork; the tab controller went); 1305.6 with chat's go-live and Rundown event cards (2026-11-07, Live #653: public/js/chat.js renderChatEventCard and its styles, ~9 KB); 1294.8 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 1293.3 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 1291.2 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 1280.1 (2026-11-03: the chat name card shows at once, the logs give up after ten seconds; headroom for openvibe-shared 4.107.3); 1279.2 (2026-11-03: openvibe-shared 4.107.0, open.<tld> brands; the home's reveal scroll listener hooked once); 1278.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack so the site's name reads whole on a phone); 1277.1 (2026-11-02: the home's money four as one 'None yet' chip while all four are zero, open item 135); 1275.2 (2026-11-02: route placeholders in ov-skeleton.js, L52 step 6: a loading route shows its shape, not an empty box); 1268.4 (2026-11-02: openvibe-shared 4.103.0, the puzzle glyph, two-line sidebar rows, boost's fix and eight solids; the 404 offers the channel for a bare /name); 1266.3 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
++ jsBrotliKB: 233.7, // 233.7 with the chat UI gone (2026-11-07: the home page no longer downloads the chat — public/js/chat.js (91.6 KB brotli) and public/js/chat-shared.js left the shell; measured 308.3 before); 308.3 with chat-core.js (2026-11-07: the part of chat every page needs moved out of chat.js) on openvibe-shared 4.133.0 (306.5); 306.5 with openvibe-shared 4.133.0 (2026-11-07: your feeds in the menu, the navbar keeps the Frame cards again, Messages and the balances on every signed-in site, and your online status); 306 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 305.7 with the network section's script (2026-11-07); 305.5 with chat's go-live and Rundown event cards (2026-11-07, Live #653); 303.1 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 302.9 with the Agents live row (Agent Streams S2) on openvibe-shared 4.118.0; 302.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 302.1 (2026-11-06: TTS speaks emotes by name, ttsSpeakableEmotes in chat-shared.js, Live #626); 301.7 with the agent stream's route (2026-11-06: the route in app.js and its shape in ov-skeleton.js; the agent's own scripts stay route-loaded, Live #622); 301.6 (2026-11-06: the Share button sweep — the shared kit's Share button markup in app.js, Live #617); 301.2 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 300.1 (2026-11-03: every api() read gives up after 20 s, Live #589; openvibe-shared 4.107.3); 299.3 (2026-11-02: openvibe-shared 4.106.2, the balances stack); 298.4 (2026-11-02: route placeholders, L52 step 6); 296.7 (2026-11-02: openvibe-shared 4.103.0 and the 404's channel offer); 296.2 (2026-11-02: openvibe-shared 4.95.0, keyboard-reachable scrollers; the phone Pastes rail); 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
// Code that must never be part of the home page's first load.
const FORBIDDEN_ON_HOME = ['/js/broadcast.js', '/js/broadcast-workspace.js', '/js/dashboard.js', '/js/call.js', '/js/voice-channels.js',
-- '/js/stream-player.js', '/js/app-channel.js', '/js/rundown.js', '/js/pastes.js', '/css/features/broadcast.css', '/css/features/channel.css'];
++ '/js/stream-player.js', '/js/app-channel.js', '/js/rundown.js', '/js/pastes.js', '/css/features/broadcast.css', '/css/features/channel.css',
++ // The chat UI is a lazy feature (2026-11-07): the routes that have chat ask for it, the home page
++ // does not. /js/chat-core.js stays — that is the part of chat every page needs.
++ '/js/chat.js', '/js/chat-shared.js', '/css/chat-shared.css'];
// perf-budget's measure() fetches from a running server; this script stays offline, so it reads
// what the server would render for "/" and hands check() the same measurement shape.
ddiff --git a/test/browser/smoke.js b/test/browser/smoke.js
iindex 9f010da5..e9456687 100644
---- a/test/browser/smoke.js
++++ b/test/browser/smoke.js
@@@ -215,6 +215,37 @@ const pageState = `(() => ({
cdp.close();
}
++ // 3b2. The home page carries no chat: it never loads the chat UI as a script, and the global chat
++ // button brings it when someone uses it. Once the page has settled, the idle prefetch may warm it
++ // (a <link rel="prefetch">, type Other, skipped on Save-Data and 2G), so the channel most people open
++ // next from here has its chat cached (Opus, 11-07: the home -> channel path is the common one).
++ if (!ONLY_SIGNED && !ROUTES_ONLY) {
++ // Wide: under 760 px the shared app bar takes the place of the floating buttons.
++ const { cdp, errors, scripts } = await openPage(1366);
++ await cdp.send('Page.navigate', { url: BASE + '/' });
++ // Long enough for the idle prefetch (the loader waits for the page to settle, then idles).
++ await sleep(SETTLE + 6000);
++ const chatScripts = scripts.filter((u) => /\/js\/chat(-shared)?\.js(\?|$)/.test(u));
++ if (chatScripts.length) fail(`the home page downloaded the chat UI: ${chatScripts.map((u) => u.split('/').pop()).join(', ')}`);
++ else pass('the home page does not download the chat UI');
++ const fab = await cdp.evaluate(`(() => { const b = document.getElementById('floating-chat-fab'); return { visible: !!b && getComputedStyle(b).display !== 'none' && b.getBoundingClientRect().width > 0 }; })()`);
++ if (!fab.visible) fail('the floating global chat button is not on the home page');
++ else {
++ await cdp.evaluate(`document.getElementById('floating-chat-fab').click()`);
++ await sleep(SETTLE);
++ const st = await cdp.evaluate(`(() => {
++ const w = document.getElementById('floating-chat-widget');
++ return { open: !!w && getComputedStyle(w).display !== 'none' && w.getBoundingClientRect().width > 0 };
++ })()`);
++ const brought = scripts.some((u) => /\/js\/chat\.js(\?|$)/.test(u));
++ if (!brought) fail('the global chat button did not bring the chat UI');
++ else if (!st.open) fail('the global chat widget did not open');
++ else pass('the global chat button brings the chat UI and opens the widget');
++ }
++ if (errors.length) fail(`errors on the home page: ${errors.slice(0, 3).join(' | ')}`);
++ cdp.close();
++ }
++
// 3c. A guest on /broadcast gets the sign-in gate, not the slot workspace, and sends no write.
if (!ONLY_SIGNED && !ROUTES_ONLY) {
const { cdp, errors } = await openPage(412);
ddiff --git a/test/chat-pages-scripts.test.js b/test/chat-pages-scripts.test.js
iindex 60369b9a..d22b7146 100644
---- a/test/chat-pages-scripts.test.js
++++ b/test/chat-pages-scripts.test.js
@@@ -3,6 +3,9 @@
// links and TTS text with OpenVibeChat. The popout chat page did not (2026-10-17): every name clicked there stayed on
// "Loading..." with "OpenVibeChat is not defined".
//
++// The main page loads the chat UI as a lazy feature (public/features.json → chatUI) since 2026-11-07, so the
++// order is checked there too: the feature's own scripts run in the order they are listed.
++//
// node test/chat-pages-scripts.test.js
const assert = require('assert');
const fs = require('fs');
@@@ -11,10 +14,20 @@ const path = require('path');
const pub = path.join(__dirname, '..', 'public');
const pages = fs.readdirSync(pub).filter((f) => f.endsWith('.html')).map((f) => [f, fs.readFileSync(path.join(pub, f), 'utf8')])
.filter(([, html]) => /<script[^>]+src="\/js\/chat\.js[?"]/.test(html));
--assert.ok(pages.some(([f]) => f === 'popout-chat.html') && pages.some(([f]) => f === 'index.html'), 'the main page and the popout load chat.js');
++assert.ok(pages.some(([f]) => f === 'popout-chat.html'), 'the popout loads chat.js');
++assert.ok(!pages.some(([f]) => f === 'index.html'), 'the main page does not load chat.js itself (it is a lazy feature)');
for (const [file, html] of pages) {
const shared = html.search(/<script[^>]+src="\/js\/chat-shared\.js[?"]/);
const chat = html.search(/<script[^>]+src="\/js\/chat\.js[?"]/);
assert.ok(shared >= 0 && shared < chat, `${file} loads chat-shared.js before chat.js`);
}
console.log(`chat pages scripts: ${pages.length} pages load chat-shared.js before chat.js`);
++
++// The feature that carries the chat on the main page keeps the same order.
++const reg = JSON.parse(fs.readFileSync(path.join(pub, 'features.json'), 'utf8'));
++const chatUi = reg.features.chatUI;
++assert.ok(chatUi, 'features.json needs a chatUI feature');
++const shared = chatUi.js.indexOf('/js/chat-shared.js');
++const chat = chatUi.js.indexOf('/js/chat.js');
++assert.ok(shared >= 0 && chat >= 0 && shared < chat, 'the chatUI feature loads chat-shared.js before chat.js');
++console.log('chat pages scripts: the chatUI feature loads chat-shared.js before chat.js');
ddiff --git a/test/chat-ui-lazy.test.js b/test/chat-ui-lazy.test.js
nnew file mode 100644
iindex 00000000..50f07e78
---- /dev/null
++++ b/test/chat-ui-lazy.test.js
@@@ -0,0 +1,106 @@
++'use strict';
++/**
++ * The chat UI is a lazy feature (public/features.json → chatUI): a page without chat does not
++ * download public/js/chat.js (340 KB) or public/js/chat-shared.js. This checks the three ways it
++ * could creep back:
++ *
++ * 1. the home page's own HTML must not name chat.js, chat-shared.js or chat-shared.css;
++ * 2. the home page never loads the chat UI: no home feature depends on it, and it is not a
++ * dependency of anything the home prefetches. It is prefetched itself, by name, in
++ * home.idle (Opus, 11-07): a <link rel="prefetch"> once the page has settled, so the channel
++ * most people open next from home has its chat cached; it never runs on the home page;
++ * 3. every inline handler (onclick="…") in the shell or in a fragment that calls a function of
++ * the chat UI must be listed in the chatUI feature's stubs, or the click does nothing on a
++ * page where the chat has not loaded.
++ *
++ * node test/chat-ui-lazy.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const ROOT = path.join(__dirname, '..');
++const PUB = path.join(ROOT, 'public');
++const reg = JSON.parse(fs.readFileSync(path.join(PUB, 'features.json'), 'utf8'));
++const index = fs.readFileSync(path.join(PUB, 'index.html'), 'utf8');
++const chatJs = fs.readFileSync(path.join(PUB, 'js', 'chat.js'), 'utf8');
++let pass = 0;
++const ok = (m) => { pass++; console.log(' ok -', m); };
++
++const CHAT_UI = reg.features.chatUI;
++assert.ok(CHAT_UI, 'features.json needs a chatUI feature');
++assert.ok(CHAT_UI.js.includes('/js/chat.js') && CHAT_UI.js.includes('/js/chat-shared.js'), 'chatUI carries the chat scripts');
++assert.ok(CHAT_UI.css.includes('/css/chat-shared.css'), 'chatUI carries the chat stylesheet');
++assert.ok(CHAT_UI.js.indexOf('/js/chat-shared.js') < CHAT_UI.js.indexOf('/js/chat.js'), 'chat-shared.js loads before chat.js (it builds the menus chat.js shows)');
++
++// 1. The home page names none of it.
++// (The asset manifest lists every file with its hash; only a tag counts as a request. The
++// <noscript> fallbacks are not fetched by a browser with JavaScript on.)
++const assets = require('../server/web/assets');
++const doc = assets.document('index.html').html;
++const home = assets.renderRoute(doc, '/');
++const noNoscript = (html) => html.replace(/<noscript>[\s\S]*?<\/noscript>/gi, '');
++for (const gone of ['<script src="/js/chat.js', '<script src="/js/chat-shared.js', '<link rel="stylesheet" href="/css/chat-shared.css']) {
++ assert.ok(!noNoscript(home).includes(gone), `the home page must not ship ${gone}`);
++ assert.ok(!noNoscript(index).includes(gone), `index.html must not load ${gone} eagerly`);
++}
++ok('the home page ships neither the chat scripts nor the chat stylesheet');
++
++// The pages that have chat ask for it.
++for (const [url, want] of [['/@someone', '/js/app-channel.js'], ['/chat', '/js/app-chatpage.js'], ['/broadcast', '/js/broadcast.js']]) {
++ const html = assets.renderRoute(doc, url);
++ assert.ok(html.includes('<script src="/js/chat.js?v='), `${url} should load the chat UI`);
++ assert.ok(html.includes('<script src="/js/chat-shared.js?v='), `${url} should load chat-shared.js`);
++ assert.ok(html.includes(`<script src="${want}?v=`), `${url} should load ${want}`);
++ assert.ok(html.indexOf('/js/chat.js?') < html.indexOf(`${want}?`), `${url}: the chat UI comes before ${want}`);
++}
++ok('the channel, chat and broadcast pages load the chat UI, and it comes first');
++
++// 2. The home page never loads the chat UI; its idle prefetch names it once, directly.
++const featureDeps = (name, seen = new Set()) => {
++ if (seen.has(name)) return seen;
++ seen.add(name);
++ for (const d of reg.features[name].deps || []) featureDeps(d, seen);
++ return seen;
++};
++for (const route of reg.routes) {
++ if (route.path !== '^/$') continue;
++ for (const f of route.features) {
++ const closure = featureDeps(f);
++ assert.ok(!closure.has('chatUI'), `the home route's ${f} would pull the chat UI`);
++ for (const idle of reg.features[f].idle || []) {
++ if (idle === 'chatUI') continue;
++ const idleClosure = featureDeps(idle);
++ assert.ok(!idleClosure.has('chatUI'), `the home page's idle prefetch of ${idle} would pull the chat UI as a dependency`);
++ }
++ }
++}
++assert.ok((reg.features.home.idle || []).includes('chatUI'), 'the home page warms the chat UI at idle, by name');
++ok('the home page never loads the chat UI, and warms it at idle');
++
++// 3. Every inline handler that calls the chat UI is stubbed.
++const chatFns = new Set();
++for (const m of chatJs.matchAll(/^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm)) chatFns.add(m[1]);
++for (const m of chatJs.matchAll(/^window\.([A-Za-z_$][\w$]*)\s*=/gm)) chatFns.add(m[1]);
++const stubs = new Set(CHAT_UI.stubs || []);
++const markup = [index, ...fs.readdirSync(path.join(PUB, 'fragments')).map((f) => fs.readFileSync(path.join(PUB, 'fragments', f), 'utf8'))].join('\n');
++const handlers = new Set();
++for (const m of markup.matchAll(/\bon(?:click|mousedown|mouseup|contextmenu|change|input|keydown|submit|focus|focusin|blur|load|error)\s*=\s*"([^"]*)"/gi)) {
++ for (const h of m[1].matchAll(/\b([A-Za-z_$][\w$]*)\s*\(/g)) handlers.add(h[1]);
++}
++const missing = [...handlers].filter((h) => chatFns.has(h) && !stubs.has(h));
++assert.deepStrictEqual(missing, [], `these inline handlers call the chat UI but are not stubbed: ${missing.join(', ')}`);
++ok(`${handlers.size} inline handlers checked; every one that calls the chat UI is stubbed`);
++
++// The stubs are functions the chat UI really defines (features-registry.test.js checks this too).
++for (const s of stubs) assert.ok(chatFns.has(s), `the stub ${s} is not defined by the chat UI`);
++
++// The chat UI is not a dependency of anything: it is named by the routes that have chat, so the
++// home page's prefetch of the channel page does not carry it.
++for (const [name, f] of Object.entries(reg.features)) {
++ if (name === 'chatUI') continue;
++ assert.ok(!(f.deps || []).includes('chatUI'), `${name} depends on the chat UI`);
++}
++ok('the chat UI belongs to the routes, not to another feature');
++
++console.log(`${pass} checks passed`);
ddiff --git a/test/tts-watching-optin.test.js b/test/tts-watching-optin.test.js
iindex a14f9340..35fbc8ab 100644
---- a/test/tts-watching-optin.test.js
++++ b/test/tts-watching-optin.test.js
@@@ -16,9 +16,12 @@ const fs = require('fs');
const path = require('path');
const chat = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');
++// The settings live in chat-core.js since 2026-11-07 (the part of chat every page needs); the
++// panel and the viewer branches stay with the chat itself.
++const chatCore = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chat-core.js'), 'utf8');
--assert.match(chat, /\n {4}ttsHearWatching: false, /, 'off by default');
--assert.match(chat, /\n {4}ttsEnabled: true, /, 'the sender switch keeps its default');
++assert.match(chatCore, /\n {4}ttsHearWatching: false, /, 'off by default');
++assert.match(chatCore, /\n {4}ttsEnabled: true, /, 'the sender switch keeps its default');
// Both viewer branches (browser speech and server audio) need the new setting.
const viewerSpeak = chat.match(/\} else if \(([^)]*?)isChatTTSEnabled\(\) && !_isOwnTtsMessage\(msg\) && _isTTSEnabledForSource\(msg\.source_platform\)\) \{\n\s*(speakTTS|playTTSAudio)\(/g) || [];
@@@ -31,7 +34,7 @@ assert.ok(chat.includes('<span>Read TTS Aloud On Channels I Watch</span>') && ch
assert.ok(chat.includes("if (key === 'ttsHearWatching' && !chatSettings.ttsHearWatching && typeof cancelAllTTS === 'function') cancelAllTTS();"));
// Not part of the synced record (Chat's chat.tts_defaults schema has no such field).
--const ttsModule = chat.slice(chat.indexOf('function _ttsModuleFromSettings'), chat.indexOf('const CHAT_MODULES'));
++const ttsModule = chatCore.slice(chatCore.indexOf('function _ttsModuleFromSettings'), chatCore.indexOf('const CHAT_MODULES'));
assert.ok(!ttsModule.includes('ttsHearWatching'), 'local to this browser');
console.log('tts watching opt-in: viewers hear TTS from the stream only, unless they turn on reading it here');