diff --git a/CHANGELOG.md b/CHANGELOG.md index 439337d..881467e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Lumi changelog +## 0.2.15 + +- Fixed Lumi's own Twitch, YouTube, and Discord messages being omitted from native chat overlays and added platform-specific plus linked-Lumi-account user blocking. +- Replaced text platform/badge labels with platform artwork, official Twitch badge images where available, and image fallbacks for other badge types. +- Added server-normalized unsaved source previews, temporary website-CSS previews, visible draft/revert controls, contrast backgrounds, an alignment grid, and reachable sticky source actions. +- Hardened alert-site transparency with an optional page/root guard that remains effective when apps such as alert providers modify their document after loading. + ## 0.2.14 - Added a native Lumi chat overlay source that combines live Twitch, YouTube, and Discord messages with optional per-channel filters. diff --git a/TODO.md b/TODO.md index 6efac2c..9bbfecc 100644 --- a/TODO.md +++ b/TODO.md @@ -28,7 +28,12 @@ Custom CSS behavior while keeping the page isolated from Lumi sessions and local network targets. A native multi-platform Lumi chat source now combines connected Twitch, YouTube, and Discord chats with channel filters, full visual controls, animations, timeout removal, short reconnect replay, Twitch emotes, and isolated -custom CSS. +custom CSS. The editor now previews server-normalized unsaved changes (including +temporary website CSS), marks/reverts drafts, provides contrast and grid views, +and keeps save actions reachable. Alert documents also retain transparent page +and app-root backgrounds after late DOM/style changes. Chat includes Lumi's own +platform messages, image-based platform/badge marks, and server-enforced platform +or linked-Lumi-account user blocking. Remaining work: @@ -686,6 +691,8 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no ## Done +- 2026-07-19: Fixed native chat overlays to include Lumi's own platform messages; added `platform:username|id` and linked `lumi:username|id` blocking before scoped delivery; replaced text platform/badge labels with images and official Twitch badge catalog artwork; added normalized unsaved source previews, secured website-CSS drafts, visible dirty/revert state, sticky save controls, contrast backgrounds, and a placement grid; and hardened transparency against late-loading alert-app DOM/style changes. + - 2026-07-19: Added the native Lumi chat overlay source with combined Twitch, YouTube, and Discord messages; optional platform/channel filters; avatars, badges, Twitch emotes, timestamps, typography, transparent colors, alignment and spacing; entry/exit animations; bounded timeout/capacity removal; isolated CSS; representative editor previews; and short-lived reconnect replay through the existing scoped overlay event stream. - 2026-07-18: Replaced frame-only website styling with OBS-style document CSS injection. External alert pages now receive OBS's transparent body CSS by default plus administrator selectors inside the website document; scoped revocable source tickets, opaque sandboxing, no-referrer handling, bounded public fetching, and private-network blocking protect Lumi and its overlay secrets. diff --git a/docs/obs-overlays.md b/docs/obs-overlays.md index d61c3a5..b417bbd 100644 --- a/docs/obs-overlays.md +++ b/docs/obs-overlays.md @@ -80,6 +80,18 @@ top-or-bottom placement, timeout removal, and separate entry and exit animations. Twitch emotes are displayed as images. The editor canvas supplies sample messages so appearance can be configured before the stream is live. +Lumi includes messages sent by its own connected bot accounts. Platform marks +are shown as icons, and chat badges are rendered as images. Twitch badge artwork +is resolved from Twitch's global and channel badge catalogs; a local image +fallback is used when a provider does not expose badge artwork. + +The optional hidden-user list accepts one rule per line in the form +`platform:username|user-id`. Twitch, YouTube, and Discord rules match only that +platform. A `lumi:internal-name|user-id` rule uses Lumi's linked identity model, +so one rule hides every connected platform identity belonging to that Lumi user. +Filtering is enforced before a message enters an overlay's scoped event stream +and is also checked by the renderer. + Advanced CSS is isolated to that chat source and is appended after the visual settings. Stable selectors include `.chat`, `.message`, `.avatar`, `.content`, `.meta`, `.platform`, `.author`, `.badge`, `.timestamp`, `.text`, and `.emote`. @@ -134,6 +146,12 @@ compatibility mode is available for private/local pages and unusual sites that cannot run through document injection; direct mode intentionally cannot change cross-origin page content. +**Keep the website background transparent** is enabled by default. It protects +the page, body, and common application roots with a transparent background and +reapplies that protection when a late-loading alert application changes its DOM +or inline styles. It can be disabled for a website that intentionally needs an +opaque page background. + Lumi applies OBS's standard transparent Browser Source CSS to every clean render page and shows the same copyable value in the Browser Source setup section: @@ -145,6 +163,21 @@ The Lumi document, canvas stage, embedded frame surface, and injected website body are transparent by default. Administrators can extend or override the default stylesheet per source just as they do in OBS Browser Source properties. +## Visual editing workflow + +Existing sources use a draft preview workflow. Changing a field sends the draft +through the same server normalizer used for saving and updates the canvas after a +short delay without changing the stored source. Website URLs and CSS receive a +short-lived, access-controlled preview document, so their unsaved appearance is +also visible. Each changed card is marked as unsaved and offers **Save source** +or **Undo unsaved changes**. + +The preview can switch between transparency checkerboard, dark, and light +backgrounds to expose unwanted opaque areas or low-contrast text. An optional +grid adds five-percent lines and canvas midpoints. Source save controls remain +sticky at the bottom of long settings panels, while drag, resize, keyboard +nudge, anchors, snapping, crop, zoom, and precise number fields remain available. + ## OBS WebSocket The connector is disabled by default. Enable OBS WebSocket in OBS, keep its diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index b388eeb..353799f 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -14,7 +14,7 @@ editable: false Lumi is the core web UI and bot runtime. ## Runtime Package: lumi-bot -Version: 0.2.14 +Version: 0.2.15 ## Routes - GET /api/events - POST /api/destructive-confirmations diff --git a/package-lock.json b/package-lock.json index 1079ddf..801b709 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lumi-bot", - "version": "0.2.14", + "version": "0.2.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lumi-bot", - "version": "0.2.14", + "version": "0.2.15", "dependencies": { "adm-zip": "^0.5.12", "better-sqlite3": "^11.5.0", diff --git a/package.json b/package.json index a86d422..93ee219 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.2.14", + "version": "0.2.15", "private": true, "type": "commonjs", "scripts": { diff --git a/release-index.json b/release-index.json index 3bde73b..ba9ce01 100644 --- a/release-index.json +++ b/release-index.json @@ -2,6 +2,36 @@ "schema_version": 1, "channel": "stable", "releases": [ + { + "version": "0.2.15", + "ref": "refs/tags/v0.2.15", + "released_at": "2026-07-19", + "installable": true, + "rollback_safe": true, + "replaces_versions": [ + "1.2.0" + ], + "data_policy": "preserve", + "dependency_policy": "sync_on_restart", + "migration_notes": "Improves native chat delivery, linked-user filtering and artwork, secured unsaved overlay previews, editing controls, and alert transparency. Existing overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets are preserved.", + "plugins": { + "auto-vc": "0.1.6", + "birthday": "0.1.3", + "economy-framework": "0.2.10", + "economy-games": "0.1.7", + "expression-interaction": "0.2.1", + "lumi_ai": "0.8.5", + "moderation": "0.1.5", + "okf": "0.1.1", + "quotes": "0.1.2", + "sample-plugin": "0.1.0", + "throne_wishlist": "0.1.2", + "welcome_messages": "0.1.1" + }, + "tools": { + "lumi_ai_web_search": "0.1.1" + } + }, { "version": "0.2.14", "ref": "refs/tags/v0.2.14", diff --git a/scripts/verify-overlay-web-documents.js b/scripts/verify-overlay-web-documents.js index d611550..fb0e9f8 100644 --- a/scripts/verify-overlay-web-documents.js +++ b/scripts/verify-overlay-web-documents.js @@ -1,7 +1,8 @@ const assert = require("assert"); const { injectDocumentCss, - loadInjectedOverlayDocument + loadInjectedOverlayDocument, + TRANSPARENT_WEBSITE_GUARD } = require("../src/services/overlay-web-documents"); const { OBS_BROWSER_DEFAULT_CSS } = require("../src/services/obs-browser-defaults"); @@ -14,9 +15,20 @@ async function run() { assert(injected.includes('')); assert(injected.includes('`; - const loadEndScript = loadEndCssScript(customCss); + const forceTransparentBackground = options.forceTransparentBackground !== false; + const style = ``; + const loadEndScript = loadEndCssScript(customCss, forceTransparentBackground); if (/]*>/i.test(document)) { document = document.replace(/]*>/i, (match) => `${match}${headStart}`); document = /<\/head\s*>/i.test(document) @@ -151,7 +160,7 @@ async function loadInjectedOverlayDocument(url, customCss, options = {}) { const html = await boundedText(response, Number(options.maxBytes) || MAX_DOCUMENT_BYTES); if (!html.trim()) throw new Error("The website returned an empty document."); return { - html: injectDocumentCss(html, response.url || url, customCss), + html: injectDocumentCss(html, response.url || url, customCss, options), upstreamUrl: response.url || url }; } finally { @@ -164,5 +173,6 @@ module.exports = { injectDocumentCss, isPrivateAddress, isPrivateTarget, - loadInjectedOverlayDocument + loadInjectedOverlayDocument, + TRANSPARENT_WEBSITE_GUARD }; diff --git a/src/services/twitch-chat-assets.js b/src/services/twitch-chat-assets.js new file mode 100644 index 0000000..1cbed02 --- /dev/null +++ b/src/services/twitch-chat-assets.js @@ -0,0 +1,71 @@ +const { getSetting } = require("./settings"); + +const badgeCatalogs = new Map(); +const CACHE_MS = 30 * 60 * 1000; + +function fallbackBadges(badges = {}) { + return Object.entries(badges).map(([label]) => ({ label, image: null })); +} + +async function badgeRequest(path) { + const clientId = String(getSetting("twitch_client_id", "") || "").trim(); + const token = String(getSetting("twitch_bot_oauth", "") || "").trim().replace(/^oauth:/i, ""); + if (!clientId || !token) throw new Error("Twitch badge credentials are unavailable."); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2500); + try { + const response = await fetch(`https://api.twitch.tv/helix/chat/badges${path}`, { + headers: { "Client-Id": clientId, Authorization: `Bearer ${token}` }, + signal: controller.signal + }); + if (!response.ok) throw new Error(`Twitch badge request returned HTTP ${response.status}.`); + return response.json(); + } finally { + clearTimeout(timeout); + } +} + +function indexCatalog(payload) { + const catalog = new Map(); + for (const set of payload?.data || []) { + for (const version of set.versions || []) { + catalog.set(`${set.set_id}/${version.id}`, { + label: version.title || set.set_id, + image: version.image_url_2x || version.image_url_1x || version.image_url_4x || null + }); + } + } + return catalog; +} + +async function loadCatalog(roomId) { + const key = String(roomId || "global"); + const cached = badgeCatalogs.get(key); + if (cached && cached.expiresAt > Date.now()) return cached.promise; + const promise = Promise.allSettled([ + badgeRequest("/global"), + roomId ? badgeRequest(`?broadcaster_id=${encodeURIComponent(roomId)}`) : Promise.resolve({ data: [] }) + ]).then((results) => { + const catalog = new Map(); + for (const result of results) { + if (result.status !== "fulfilled") continue; + for (const [id, badge] of indexCatalog(result.value)) catalog.set(id, badge); + } + return catalog; + }); + badgeCatalogs.set(key, { expiresAt: Date.now() + CACHE_MS, promise }); + return promise; +} + +async function resolveTwitchBadges(roomId, badges = {}) { + const entries = Object.entries(badges || {}); + if (!entries.length) return []; + try { + const catalog = await loadCatalog(roomId); + return entries.map(([setId, version]) => catalog.get(`${setId}/${version}`) || { label: setId, image: null }); + } catch { + return fallbackBadges(badges); + } +} + +module.exports = { resolveTwitchBadges }; diff --git a/src/services/twitch.js b/src/services/twitch.js index 7c65263..754b2fb 100644 --- a/src/services/twitch.js +++ b/src/services/twitch.js @@ -4,6 +4,7 @@ const { incrementMessages } = require("./stats"); const { ensureUserForIdentity } = require("./users"); const { createLogger } = require("./logger"); const { publishOverlayChatMessage } = require("./overlay-chat"); +const { resolveTwitchBadges } = require("./twitch-chat-assets"); const twitchLog = createLogger("platform:twitch", { category: "integration" }); @@ -40,11 +41,17 @@ async function startTwitchBot({ commandRouter } = {}) { }); client.on("message", async (channel, tags, message, self) => { - const userId = tags["user-id"]; + const userId = tags["user-id"] || (self ? `lumi:${username.toLowerCase()}` : ""); if (!userId) { return; } - const displayName = tags["display-name"] || tags.username; + const displayName = tags["display-name"] || tags.username || username; + const profile = self ? null : ensureUserForIdentity({ + provider: "twitch", + providerUserId: userId, + displayName + }); + const badges = await resolveTwitchBadges(tags["room-id"], tags.badges || {}); publishOverlayChatMessage({ id: tags.id, platform: "twitch", @@ -56,7 +63,8 @@ async function startTwitchBot({ commandRouter } = {}) { name: displayName, username: tags.username, color: tags.color, - badges: Object.keys(tags.badges || {}) + badges, + lumi: profile ? { id: profile.id, username: profile.internal_username } : null }, emotes: Object.entries(tags.emotes || {}).flatMap(([id, positions]) => (positions || []).map((position) => { const [start, end] = String(position).split("-").map(Number); @@ -64,11 +72,6 @@ async function startTwitchBot({ commandRouter } = {}) { })) }); if (self) return; - const profile = ensureUserForIdentity({ - provider: "twitch", - providerUserId: userId, - displayName - }); incrementMessages(profile.id); if (commandRouter) { diff --git a/src/services/youtube.js b/src/services/youtube.js index 0cfc792..f26916d 100644 --- a/src/services/youtube.js +++ b/src/services/youtube.js @@ -123,6 +123,13 @@ async function handleChatItem(state, liveChatId, item) { } const displayName = author.displayName || "YouTube User"; const avatar = author.profileImageUrl || null; + const isSelf = Boolean(state.channelId && author.channelId === state.channelId); + const profile = isSelf ? null : ensureUserForIdentity({ + provider: "youtube", + providerUserId: author.channelId, + displayName, + avatar + }); publishOverlayChatMessage({ id: item.id, platform: "youtube", @@ -138,16 +145,11 @@ async function handleChatItem(state, liveChatId, item) { name: displayName, username: displayName, avatar, - badges: [author.isChatOwner && "Owner", author.isChatModerator && "Moderator", author.isChatSponsor && "Member"].filter(Boolean) + badges: [author.isChatOwner && "Owner", author.isChatModerator && "Moderator", author.isChatSponsor && "Member"].filter(Boolean), + lumi: profile ? { id: profile.id, username: profile.internal_username } : null } }); - if (state.channelId && author.channelId === state.channelId) return; - const profile = ensureUserForIdentity({ - provider: "youtube", - providerUserId: author.channelId, - displayName, - avatar - }); + if (isSelf) return; incrementMessages(profile.id); if (!state.commandRouter) { diff --git a/src/web/public/icons/badges/generic.svg b/src/web/public/icons/badges/generic.svg new file mode 100644 index 0000000..6784531 --- /dev/null +++ b/src/web/public/icons/badges/generic.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/badges/member.svg b/src/web/public/icons/badges/member.svg new file mode 100644 index 0000000..d17565e --- /dev/null +++ b/src/web/public/icons/badges/member.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/badges/moderator.svg b/src/web/public/icons/badges/moderator.svg new file mode 100644 index 0000000..5932ea2 --- /dev/null +++ b/src/web/public/icons/badges/moderator.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/badges/owner.svg b/src/web/public/icons/badges/owner.svg new file mode 100644 index 0000000..4dbb469 --- /dev/null +++ b/src/web/public/icons/badges/owner.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/platforms/discord.svg b/src/web/public/icons/platforms/discord.svg new file mode 100644 index 0000000..7fe569a --- /dev/null +++ b/src/web/public/icons/platforms/discord.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/platforms/lumi.svg b/src/web/public/icons/platforms/lumi.svg new file mode 100644 index 0000000..bd3b0da --- /dev/null +++ b/src/web/public/icons/platforms/lumi.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/platforms/twitch.svg b/src/web/public/icons/platforms/twitch.svg new file mode 100644 index 0000000..bb219dc --- /dev/null +++ b/src/web/public/icons/platforms/twitch.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/icons/platforms/youtube.svg b/src/web/public/icons/platforms/youtube.svg new file mode 100644 index 0000000..ce199af --- /dev/null +++ b/src/web/public/icons/platforms/youtube.svg @@ -0,0 +1 @@ + diff --git a/src/web/public/overlay-management.css b/src/web/public/overlay-management.css index f75559b..6024582 100644 --- a/src/web/public/overlay-management.css +++ b/src/web/public/overlay-management.css @@ -387,6 +387,28 @@ margin-top: var(--lumi-space-1); } +.overlay-preview-tools { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--lumi-space-2); + margin-bottom: var(--lumi-space-2); + color: var(--lumi-text-muted); + font-size: 0.82rem; +} + +.overlay-preview-tools .button { + min-height: 2rem; + padding-block: 0.3rem; + font-size: 0.78rem; +} + +.overlay-preview-tools [aria-pressed="true"] { + border-color: var(--lumi-primary); + background: color-mix(in srgb, var(--lumi-primary) 20%, var(--lumi-surface)); +} + .overlay-canvas-frame { width: 100%; aspect-ratio: var(--overlay-aspect, 16 / 9); @@ -404,6 +426,44 @@ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04); } +.overlay-canvas-frame.preview-bg-dark { + background: #070b12; +} + +.overlay-canvas-frame.preview-bg-light { + background: #f4f7fb; +} + +.overlay-canvas-frame.show-grid .overlay-canvas::after { + content: ""; + position: absolute; + z-index: 900; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(to right, rgba(56,189,248,.28) 1px, transparent 1px), + linear-gradient(to bottom, rgba(56,189,248,.28) 1px, transparent 1px), + linear-gradient(to right, rgba(56,189,248,.6) 1px, transparent 1px), + linear-gradient(to bottom, rgba(56,189,248,.6) 1px, transparent 1px); + background-size: 5% 5%, 5% 5%, 50% 50%, 50% 50%; +} + +.overlay-source-card.is-dirty { + border-color: color-mix(in srgb, var(--lumi-accent, var(--lumi-primary)) 70%, var(--lumi-border)); + box-shadow: inset 3px 0 color-mix(in srgb, var(--lumi-accent, var(--lumi-primary)) 75%, transparent); +} + +.overlay-source-save-row { + position: sticky; + z-index: 10; + bottom: 0; + margin: 0 calc(-1 * var(--lumi-space-3)) calc(-1 * var(--lumi-space-3)); + padding: var(--lumi-space-3); + border-top: 1px solid var(--lumi-border); + background: color-mix(in srgb, var(--lumi-surface) 94%, transparent); + backdrop-filter: blur(10px); +} + .overlay-canvas { position: relative; width: 100%; diff --git a/src/web/public/overlay-management.js b/src/web/public/overlay-management.js index f6ea3a3..e547aff 100644 --- a/src/web/public/overlay-management.js +++ b/src/web/public/overlay-management.js @@ -114,18 +114,20 @@ sync(); }); - document.querySelectorAll("[data-module-editor]").forEach((form) => { + function syncModuleFields(form) { const select = form.querySelector("[data-module-type-select]"); if (!select) return; - const sync = () => { - form.querySelectorAll("[data-module-fields]").forEach((group) => { - const visible = group.dataset.moduleFields === select.value; - group.hidden = !visible; - group.querySelectorAll("input, select, textarea, button").forEach((field) => { field.disabled = !visible; }); - }); - }; - select.addEventListener("change", sync); - sync(); + form.querySelectorAll("[data-module-fields]").forEach((group) => { + const visible = group.dataset.moduleFields === select.value; + group.hidden = !visible; + group.querySelectorAll("input, select, textarea, button").forEach((field) => { field.disabled = !visible; }); + }); + } + + document.querySelectorAll("[data-module-editor]").forEach((form) => { + const select = form.querySelector("[data-module-type-select]"); + select?.addEventListener("change", () => syncModuleFields(form)); + syncModuleFields(form); }); const editorRoot = document.querySelector("[data-overlay-editor]"); @@ -135,6 +137,7 @@ if (editorRoot && editorData && renderer) { const state = JSON.parse(editorData.textContent || "{}"); + const savedModules = new Map(state.scenes.flatMap((scene) => scene.modules).map((module) => [module.id, JSON.parse(JSON.stringify(module))])); const canvas = editorRoot.querySelector("[data-overlay-canvas]"); const layer = editorRoot.querySelector("[data-overlay-canvas-layer]"); const sceneSelect = editorRoot.querySelector("[data-editor-scene]"); @@ -154,6 +157,7 @@ let selectedId = null; let drag = null; let elements = new Map(); + const previewRequests = new Map(); const currentScene = () => state.scenes.find((scene) => scene.id === sceneSelect.value) || state.scenes[0]; const selectedModule = () => currentScene()?.modules.find((module) => module.id === selectedId) || null; @@ -202,6 +206,73 @@ } } + function formBody(form) { + const body = {}; + for (const [key, value] of new FormData(form).entries()) body[key] = value; + return body; + } + + function setPreviewStatus(form, message, stateName = "") { + const status = form.querySelector("[data-source-preview-status]"); + if (status) status.textContent = message; + form.closest("[data-module-card]")?.classList.toggle("is-dirty", stateName === "dirty"); + if (stateName === "dirty") saveState.textContent = "Unsaved preview"; + } + + function moduleForId(moduleId) { + return state.scenes.flatMap((scene) => scene.modules).find((module) => module.id === moduleId) || null; + } + + function previewForm(form) { + const moduleId = form.dataset.moduleId; + const module = moduleForId(moduleId); + if (!module) return; + clearTimeout(previewRequests.get(moduleId)?.timer); + previewRequests.get(moduleId)?.controller?.abort(); + setPreviewStatus(form, "Previewing unsaved changes…", "dirty"); + const controller = new AbortController(); + const timer = setTimeout(async () => { + try { + const payload = await jsonRequest(`/api/admin/overlays/${state.overlayId}/modules/${moduleId}/preview`, { + method: "POST", + body: JSON.stringify(formBody(form)), + signal: controller.signal + }); + Object.assign(module, payload.module); + selectedId = moduleId; + renderCanvas(); + setPreviewStatus(form, "Live preview · not saved", "dirty"); + } catch (error) { + if (error.name !== "AbortError") setPreviewStatus(form, `Preview problem: ${error.message}`, "dirty"); + } + }, 280); + previewRequests.set(moduleId, { controller, timer }); + } + + document.querySelectorAll("[data-module-editor][data-module-id]").forEach((form) => { + const update = () => previewForm(form); + form.addEventListener("input", update); + form.addEventListener("change", update); + form.addEventListener("submit", () => { + setPreviewStatus(form, "Saving…"); + saveState.textContent = "Saving…"; + }); + form.querySelector("[data-source-revert]")?.addEventListener("click", () => { + const saved = savedModules.get(form.dataset.moduleId); + const module = moduleForId(form.dataset.moduleId); + if (!saved || !module) return; + previewRequests.get(module.id)?.controller?.abort(); + clearTimeout(previewRequests.get(module.id)?.timer); + form.reset(); + syncModuleFields(form); + Object.assign(module, JSON.parse(JSON.stringify(saved))); + selectedId = module.id; + renderCanvas(); + setPreviewStatus(form, "Saved settings restored"); + saveState.textContent = "Ready"; + }); + }); + function selectSource(moduleId, { openSettings = false } = {}) { selectedId = moduleId; layer.querySelectorAll(".lumi-overlay-module").forEach((element) => element.classList.toggle("is-selected", element.dataset.moduleId === moduleId)); @@ -389,6 +460,18 @@ editorRoot.querySelector("[data-editor-center]")?.addEventListener("click", () => transformSelected({ x: 50, y: 50, anchor: "center" })); editorRoot.querySelector("[data-editor-fit]")?.addEventListener("click", () => transformSelected({ x: 0, y: 0, width: 100, height: 100, anchor: "top-left" })); editorRoot.querySelector("[data-editor-reset]")?.addEventListener("click", () => transformSelected({ x: 0, y: 0, width: 50, height: 25, anchor: "top-left" })); + const previewFrame = editorRoot.querySelector("[data-preview-frame]"); + editorRoot.querySelectorAll("[data-preview-background]").forEach((button) => button.addEventListener("click", () => { + const background = button.dataset.previewBackground; + previewFrame?.classList.toggle("preview-bg-dark", background === "dark"); + previewFrame?.classList.toggle("preview-bg-light", background === "light"); + button.parentElement?.querySelectorAll("[data-preview-background]").forEach((item) => item.setAttribute("aria-pressed", item === button ? "true" : "false")); + })); + editorRoot.querySelector("[data-preview-grid]")?.addEventListener("click", (event) => { + const active = !previewFrame?.classList.contains("show-grid"); + previewFrame?.classList.toggle("show-grid", active); + event.currentTarget.setAttribute("aria-pressed", active ? "true" : "false"); + }); snapToggle.addEventListener("change", () => { snapHint.textContent = snapToggle.checked ? "off" : "on"; }); sceneSelect.addEventListener("change", () => { selectedId = null; renderCanvas(); }); window.addEventListener("resize", () => renderCanvas()); diff --git a/src/web/public/overlay-renderer.js b/src/web/public/overlay-renderer.js index 45ae4e6..1e086e4 100644 --- a/src/web/public/overlay-renderer.js +++ b/src/web/public/overlay-renderer.js @@ -132,7 +132,7 @@ } function chatAccepts(values, payload) { - if (!(values.platforms || []).includes(payload.platform)) return false; + if (!(values.platforms || []).includes(payload.platform) || chatUserBlocked(values, payload)) return false; const filters = Array.isArray(values.channels) ? values.channels : []; if (!filters.length) return true; const aliases = [payload.channel?.id, payload.channel?.name, payload.channel?.key] @@ -147,6 +147,27 @@ }); } + function normalizedIdentity(value) { + return String(value || "").trim().toLowerCase().replace(/^@/, ""); + } + + function chatUserBlocked(values, payload) { + return (values.blockedUsers || []).some((entry) => { + const raw = String(entry || "").trim(); + const separator = raw.indexOf(":"); + if (separator < 1) return false; + const platform = raw.slice(0, separator).toLowerCase(); + const blocked = raw.slice(separator + 1).split("|").map(normalizedIdentity).filter(Boolean); + const aliases = platform === "lumi" + ? [payload.author?.lumi?.id, payload.author?.lumi?.username] + : platform === payload.platform + ? [payload.author?.id, payload.author?.username, payload.author?.name] + : []; + const normalizedAliases = aliases.map(normalizedIdentity).filter(Boolean); + return blocked.some((value) => normalizedAliases.includes(value)); + }); + } + function chatMessageElement(payload, values, { animate = true } = {}) { const row = document.createElement("article"); row.className = `message platform-${payload.platform}`; @@ -168,9 +189,11 @@ const meta = document.createElement("div"); meta.className = "meta"; if (values.showPlatform) { - const platform = document.createElement("span"); + const platform = document.createElement("img"); platform.className = "platform"; - platform.textContent = payload.platform === "youtube" ? "YouTube" : payload.platform[0].toUpperCase() + payload.platform.slice(1); + platform.src = `/icons/platforms/${["twitch", "youtube", "discord"].includes(payload.platform) ? payload.platform : "lumi"}.svg`; + platform.alt = payload.platform === "youtube" ? "YouTube" : payload.platform[0].toUpperCase() + payload.platform.slice(1); + platform.title = platform.alt; meta.appendChild(platform); } const author = document.createElement("span"); @@ -180,14 +203,18 @@ meta.appendChild(author); if (values.showBadges) { for (const badgeData of payload.author?.badges || []) { - const badge = document.createElement(badgeData.image ? "img" : "span"); + const badge = document.createElement("img"); badge.className = "badge"; if (badgeData.image) { badge.src = badgeData.image; - badge.alt = badgeData.label || "Badge"; } else { - badge.textContent = badgeData.label; + const label = String(badgeData.label || "").toLowerCase(); + const kind = /owner|broadcaster/.test(label) ? "owner" : /moderator|mod|staff/.test(label) ? "moderator" : /member|subscriber|sub|vip/.test(label) ? "member" : "generic"; + badge.src = `/icons/badges/${kind}.svg`; } + badge.alt = badgeData.label || "Badge"; + badge.title = badge.alt; + badge.referrerPolicy = "no-referrer"; meta.appendChild(badge); } } @@ -259,12 +286,8 @@ .content { min-width:0; flex:1; } .meta { display:flex; align-items:center; justify-content:${values.horizontalAlign === "right" ? "flex-end" : values.horizontalAlign === "center" ? "center" : "flex-start"}; gap:.4em; min-width:0; margin-bottom:.12em; font-size:.78em; line-height:1.15; } .author { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:800; } - .platform { flex:0 0 auto; padding:.12em .38em; border-radius:.3em; background:rgba(255,255,255,.14); font-size:.72em; text-transform:uppercase; letter-spacing:.035em; } - .platform-twitch .platform { background:#9146ff; color:#fff; } - .platform-youtube .platform { background:#ff0033; color:#fff; } - .platform-discord .platform { background:#5865f2; color:#fff; } - .badge { flex:0 0 auto; font-size:.68em; line-height:1; padding:.18em .32em; border-radius:.25em; background:rgba(255,255,255,.14); } - img.badge { width:1.1em; height:1.1em; padding:0; object-fit:contain; background:transparent; } + .platform { flex:0 0 auto; width:1.05em; height:1.05em; border-radius:.24em; object-fit:contain; } + .badge { flex:0 0 auto; width:1.1em; height:1.1em; border-radius:.2em; object-fit:contain; } .timestamp { flex:0 0 auto; margin-left:auto; opacity:.65; font-size:.85em; } .text { overflow-wrap:anywhere; white-space:pre-wrap; } .emote { display:inline-block; width:auto; height:1.35em; margin:0 .08em; vertical-align:-.3em; object-fit:contain; } diff --git a/src/web/views/admin-overlay-detail.ejs b/src/web/views/admin-overlay-detail.ejs index 67640c0..234d969 100644 --- a/src/web/views/admin-overlay-detail.ejs +++ b/src/web/views/admin-overlay-detail.ejs @@ -97,7 +97,7 @@ <%= module.name %><%= moduleTypes.find((item) => item.id === module.type)?.label || module.type %> · <%= module.enabled ? "Visible" : "Hidden" %>
-
+
@@ -118,6 +118,7 @@ <% [["twitch","Twitch"],["youtube","YouTube"],["discord","Discord"]].forEach(([id,label]) => { %><% }) %> + @@ -136,7 +137,7 @@
Message details
- +
@@ -175,6 +176,7 @@
Website CSS

Recommended. Turn this off only if a website will not load through CSS injection.

+
Transparent page

Recommended for alerts. Lumi reapplies transparency if the website changes its page after loading.

Automatic recovery
Check website replies
@@ -194,7 +196,7 @@ -
+
Saved settings
@@ -213,12 +215,13 @@

Lumi combines live messages from the services you select. You can adjust the appearance, animations, timeout, and CSS after adding it.

Chats to include
+
-
+
@@ -248,10 +251,11 @@ diff --git a/update-manifest.json b/update-manifest.json index 4ae368f..880e920 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -1,6 +1,6 @@ { "name": "Lumi Core", - "version": "0.2.14", + "version": "0.2.15", "channel": "stable", "released_at": "2026-07-19", "compatible_from": "0.1.9", @@ -8,7 +8,7 @@ "replaces_versions": [ "1.2.0" ], - "migration_notes": "Adds the native multi-platform Lumi chat overlay source and preserves existing overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets.", + "migration_notes": "Improves native chat delivery, filtering and artwork, live unsaved overlay editing, and alert transparency. Existing overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.", "rollback_safe": true, "requirements": [ "Node.js 18 or newer" @@ -193,6 +193,18 @@ ], "rollback_safe": true, "migration_notes": "Adds the native multi-platform Lumi chat overlay source; existing overlay records, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved." + }, + { + "version": "0.2.15", + "channel": "stable", + "released_at": "2026-07-19", + "compatible_from": "0.1.9", + "migration_kind": "patch", + "replaces_versions": [ + "1.2.0" + ], + "rollback_safe": true, + "migration_notes": "Improves native chat delivery, filtering and artwork, live unsaved overlay editing, and alert transparency; existing overlay records, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved." } ] }