User:Jono Bean/common.js: Difference between revisions
From DQWiki
Jump to navigationJump to search
m (update to more robust script) |
mNo edit summary |
||
| Line 1: | Line 1: | ||
/* Lorekeeper Widget Loader - v1. | # DQ Media Wiki Lorekeeper Widget | ||
This note documents how the DQ Media Wiki widget is loaded from MediaWiki, what the current `common.js` script does, how the iframe talks to NPC Forge, and what changed in the recent hardening work. | |||
## Where the MediaWiki code lives | |||
For Jono Bean, the active widget loader is currently in: | |||
- `https://dq-nz.org/dqwiki/index.php/User:Jono_Bean/common.js` | |||
This means the widget is user-scoped rather than site-wide. Only that logged-in user sees it unless the same code is copied into a site script such as `MediaWiki:Common.js` or a gadget. | |||
## Current MediaWiki loader shape | |||
The wiki-side script: | |||
1. Reads the logged-in MediaWiki username with `mw.config.get('wgUserName')` | |||
2. Detects whether the parent wiki viewport is mobile | |||
3. Builds an iframe URL pointing at: | |||
- `https://npc-forge--npc-forge-qh4gc.asia-southeast1.hosted.app/widget` | |||
4. Passes either: | |||
- `user=<wiki username>` | |||
- or `guest=Traveler` | |||
5. Passes `isMobile=true` when the wiki viewport is narrow | |||
6. Creates a fixed-position iframe container in the bottom-right corner | |||
7. Listens for `postMessage` events from the NPC Forge widget and resizes the iframe when the widget opens, closes, or changes layout | |||
## Recommended MediaWiki `common.js` | |||
This is the current recommended loader script for the DQ Media Wiki user script: | |||
```javascript | |||
/* Lorekeeper Widget Loader - v1.2.0 (MediaWiki polished loader) */ | |||
(function () { | (function () { | ||
'use strict'; | |||
var | var CONFIG = { | ||
appOrigin: 'https://npc-forge--npc-forge-qh4gc.asia-southeast1.hosted.app', | |||
widgetPath: '/widget', | |||
containerId: 'lorekeeper-widget-container', | |||
iframeId: 'lorekeeper-widget-frame', | |||
mobileBreakpoint: 768, | |||
edgeGap: 16, | |||
desktopCollapsed: { width: 300, height: 120 }, | |||
mobileCollapsed: { width: 80, height: 80 }, | |||
desktopMax: { width: 720, height: 720 }, | |||
desktopMin: { width: 300, height: 120 }, | |||
zIndex: 99999, | |||
debug: false | |||
}; | |||
if (window.__lorekeeperWidgetLoader && window.__lorekeeperWidgetLoader.version) { | |||
return; | |||
} | |||
var state = { | |||
mounted: false, | |||
resizeTimer: 0, | |||
lastMode: null | |||
}; | |||
function log() { | |||
if (!CONFIG.debug || !window.console) return; | |||
window.console.log.apply(window.console, ['[Lorekeeper Widget]'].concat([].slice.call(arguments))); | |||
} | |||
function isMobileViewport() { | function isMobileViewport() { | ||
return window.innerWidth < | return window.innerWidth < CONFIG.mobileBreakpoint; | ||
} | |||
function currentMode() { | |||
return isMobileViewport() ? 'mobile' : 'desktop'; | |||
} | } | ||
function | function getMediaWikiUsername() { | ||
try { | try { | ||
if (!window.mw || !window.mw.config) return ''; | |||
return String(window.mw.config.get('wgUserName') || '').trim(); | |||
} catch (err) { | } catch (err) { | ||
return ''; | return ''; | ||
| Line 22: | Line 82: | ||
} | } | ||
function | function buildWidgetUrl() { | ||
var | var url = new URL(CONFIG.widgetPath, CONFIG.appOrigin); | ||
var username = | var username = getMediaWikiUsername(); | ||
if (username) { | if (username) { | ||
url.searchParams.set('user', username); | |||
} else { | } else { | ||
url.searchParams.set('guest', 'Traveler'); | |||
} | |||
url.searchParams.set('isMobile', isMobileViewport() ? 'true' : 'false'); | |||
url.searchParams.set('parentOrigin', window.location.origin); | |||
return url.toString(); | |||
} | |||
function px(value) { | |||
return Math.round(value) + 'px'; | |||
} | |||
function clampNumber(value, min, max) { | |||
return Math.max(min, Math.min(max, value)); | |||
} | |||
function normalizeSize(value, axis) { | |||
if (typeof value === 'string') { | |||
if (value === '100vw' || value === '100vh') return value; | |||
var parsed = parseFloat(value); | |||
if (!Number.isFinite(parsed)) return null; | |||
value = parsed; | |||
} | } | ||
if (typeof value !== 'number' || !Number.isFinite(value)) return null; | |||
if (isMobileViewport()) { | if (isMobileViewport()) { | ||
return axis === 'width' ? '100vw' : '100vh'; | |||
} | } | ||
var viewportLimit = axis === 'width' | |||
? window.innerWidth - (CONFIG.edgeGap * 2) | |||
: window.innerHeight - (CONFIG.edgeGap * 2); | |||
var min = CONFIG.desktopMin[axis]; | |||
var max = Math.min(CONFIG.desktopMax[axis], viewportLimit); | |||
return px(clampNumber(value, min, Math.max(min, max))); | |||
} | |||
function setStyles(element, styles) { | |||
Object.keys(styles).forEach(function (key) { | |||
element.style[key] = styles[key]; | |||
}); | |||
} | } | ||
function setCollapsedSize(container) { | function setCollapsedSize(container) { | ||
var size = isMobileViewport() ? CONFIG.mobileCollapsed : CONFIG.desktopCollapsed; | |||
setStyles(container, { | |||
width: px(size.width), | |||
height: px(size.height) | |||
}); | |||
} | |||
} | } | ||
function | function createContainer() { | ||
var | var container = document.createElement('div'); | ||
container.id = CONFIG.containerId; | |||
container.setAttribute('data-lorekeeper-widget', 'true'); | |||
setStyles(container, { | |||
position: 'fixed', | |||
right: isMobileViewport() ? '0' : px(CONFIG.edgeGap), | |||
bottom: isMobileViewport() ? '0' : px(CONFIG.edgeGap), | |||
zIndex: String(CONFIG.zIndex), | |||
pointerEvents: 'none', | |||
overflow: 'hidden', | |||
background: 'transparent', | |||
border: '0', | |||
padding: '0', | |||
margin: '0', | |||
maxWidth: '100vw', | |||
maxHeight: '100vh', | |||
transition: window.matchMedia('(prefers-reduced-motion: reduce)').matches | |||
? 'none' | |||
: 'width 180ms ease, height 180ms ease' | |||
}); | |||
setCollapsedSize(container); | setCollapsedSize(container); | ||
return container; | return container; | ||
} | } | ||
function | function createIframe() { | ||
var iframe = document.createElement('iframe'); | var iframe = document.createElement('iframe'); | ||
iframe.id = | iframe.id = CONFIG.iframeId; | ||
iframe.title = 'NPC Forge Lorekeeper'; | |||
iframe. | iframe.src = buildWidgetUrl(); | ||
iframe. | |||
iframe.setAttribute('allow', 'clipboard-write'); | iframe.setAttribute('allow', 'clipboard-write'); | ||
iframe.setAttribute('loading', 'lazy'); | iframe.setAttribute('loading', 'lazy'); | ||
iframe.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); | iframe.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin'); | ||
iframe.setAttribute('data-mode', currentMode()); | |||
setStyles(iframe, { | |||
display: 'block', | |||
width: '100%', | |||
height: '100%', | |||
border: '0', | |||
padding: '0', | |||
margin: '0', | |||
background: 'transparent', | |||
colorScheme: 'normal', | |||
pointerEvents: 'auto' | |||
}); | |||
return iframe; | return iframe; | ||
} | } | ||
function | function ensureMounted() { | ||
if (!document.body) return false; | if (!document.body) return false; | ||
var container = | var container = document.getElementById(CONFIG.containerId); | ||
var iframe = | var iframe = document.getElementById(CONFIG.iframeId); | ||
if (!container.parentNode) { | if (!container) { | ||
container = createContainer(); | |||
} | |||
if (!iframe) { | |||
iframe = createIframe(); | |||
} | |||
if (iframe.parentNode !== container) { | |||
container.appendChild(iframe); | container.appendChild(iframe); | ||
} | |||
if (container.parentNode !== document.body) { | |||
document.body.appendChild(container); | document.body.appendChild(container); | ||
} | } | ||
state.lastMode = currentMode(); | |||
state.mounted = true; | |||
log('mounted', state.lastMode); | |||
return true; | return true; | ||
} | } | ||
function applyResize( | function applyResize(width, height) { | ||
var container = document.getElementById(CONFIG.containerId); | |||
if (!container) return; | if (!container) return; | ||
var nextWidth = normalizeSize(width, 'width'); | |||
var nextHeight = normalizeSize(height, 'height'); | |||
if (nextWidth) container.style.width = nextWidth; | |||
if (nextHeight) container.style.height = nextHeight; | |||
container.style.right = isMobileViewport() ? '0' : px(CONFIG.edgeGap); | |||
container.style.bottom = isMobileViewport() ? '0' : px(CONFIG.edgeGap); | |||
} | } | ||
function handleMessage(event) { | function handleMessage(event) { | ||
if (event.origin !== | var iframe = document.getElementById(CONFIG.iframeId); | ||
if (event.origin !== CONFIG.appOrigin) return; | |||
if (iframe && event.source !== iframe.contentWindow) return; | |||
if (!event.data || event.data.type !== 'lorekeeper-resize') return; | if (!event.data || event.data.type !== 'lorekeeper-resize') return; | ||
applyResize(event.data.width, event.data.height); | |||
applyResize( | |||
} | } | ||
function | function refreshForViewport() { | ||
var container = document.getElementById( | var container = document.getElementById(CONFIG.containerId); | ||
var iframe = document.getElementById( | var iframe = document.getElementById(CONFIG.iframeId); | ||
if (!container || !iframe) return; | if (!container || !iframe) return; | ||
var | var nextMode = currentMode(); | ||
container.style.right = nextMode === 'mobile' ? '0' : px(CONFIG.edgeGap); | |||
container.style.bottom = nextMode === 'mobile' ? '0' : px(CONFIG.edgeGap); | |||
if ( | if (nextMode !== state.lastMode) { | ||
iframe.setAttribute('data- | state.lastMode = nextMode; | ||
iframe.src = | iframe.setAttribute('data-mode', nextMode); | ||
iframe.src = buildWidgetUrl(); | |||
setCollapsedSize(container); | setCollapsedSize(container); | ||
log('mode changed', nextMode); | |||
return; | return; | ||
} | } | ||
if ( | if (container.style.width === '100vw' || container.style.height === '100vh') { | ||
applyResize(container.style.width, container.style.height); | |||
} | |||
container.style.width = | } | ||
) { | |||
function handleWindowResize() { | |||
window.clearTimeout(state.resizeTimer); | |||
state.resizeTimer = window.setTimeout(refreshForViewport, 120); | |||
} | |||
function destroy() { | |||
window.clearTimeout(state.resizeTimer); | |||
window.removeEventListener('message', handleMessage, false); | |||
window.removeEventListener('resize', handleWindowResize, false); | |||
var container = document.getElementById(CONFIG.containerId); | |||
if (container && container.parentNode) { | |||
container.parentNode.removeChild(container); | |||
} | } | ||
state.mounted = false; | |||
window.__lorekeeperWidgetLoader = null; | |||
} | } | ||
function init() { | function init() { | ||
if (! | if (!ensureMounted()) { | ||
window.setTimeout(init, 50); | window.setTimeout(init, 50); | ||
return; | return; | ||
} | } | ||
window.addEventListener('message', handleMessage, false); | window.addEventListener('message', handleMessage, false); | ||
window.addEventListener('resize', handleWindowResize, false); | window.addEventListener('resize', handleWindowResize, false); | ||
window.__lorekeeperWidgetLoader = { | |||
version: '1.2.0', | |||
refresh: refreshForViewport, | |||
destroy: destroy | |||
}; | |||
} | } | ||
| Line 176: | Line 310: | ||
} | } | ||
})(); | })(); | ||
``` | |||
## What the app expects from MediaWiki | |||
The widget route now expects these query params from the wiki loader: | |||
- `user` | |||
- preferred when MediaWiki has a logged-in user | |||
- `guest` | |||
- fallback display/storage identity when no logged-in user exists | |||
- `isMobile=true` | |||
- tells the iframe to start in mobile layout | |||
- `parentOrigin` | |||
- used by the iframe so resize messages can target the wiki origin more safely | |||
The widget app normalizes `user` and `guest` before using them for local guest storage and the header badge. | |||
## Iframe message contract | |||
The NPC Forge widget sends this message back to the parent page: | |||
```json | |||
{ | |||
"type": "lorekeeper-resize", | |||
"width": 700, | |||
"height": 650 | |||
} | |||
``` | |||
Or on mobile: | |||
```json | |||
{ | |||
"type": "lorekeeper-resize", | |||
"width": "100vw", | |||
"height": "100vh" | |||
} | |||
``` | |||
The wiki loader should: | |||
- check `event.origin` | |||
- check `event.data.type === 'lorekeeper-resize'` | |||
- apply the new width and height to the fixed widget container | |||
## Recent widget fixes | |||
The NPC Forge side was recently hardened in these areas: | |||
1. Transparent embed shell | |||
- removed the large grey iframe background slab behind the Lorekeeper card | |||
2. Safer widget identity handling | |||
- `user` and `guest` values are trimmed, normalized, and bounded before use | |||
3. Better widget API routing | |||
- external hosts like the DQ Media Wiki now resolve the correct absolute API base more safely | |||
- local/private hosts no longer get mistaken for production external embeds | |||
4. Safer widget messaging | |||
- `parentOrigin` is supported for stricter `postMessage` targeting | |||
5. Cleaner widget error reporting | |||
- widget requests no longer retry the same endpoint and then misreport the failure as a fake fallback error | |||
6. Polished MediaWiki loader paste script | |||
- v1.2.0 keeps all tweakable values in one `CONFIG` block | |||
- uses `URL` / `searchParams` instead of hand-built query strings | |||
- verifies both message origin and iframe message source before resizing | |||
- clamps desktop resize requests to the viewport so the widget cannot sprawl off-screen | |||
- debounces viewport resize handling and reloads the iframe only when crossing the mobile breakpoint | |||
- exposes `window.__lorekeeperWidgetLoader.refresh()` and `.destroy()` for live wiki debugging | |||
## Capacity handling | |||
Originally, the MediaWiki widget was competing against the global AI cap used by all app features. That meant a low setting like `2` active runs could make the wiki widget fail even when only a couple of unrelated AI jobs were running elsewhere. | |||
The runtime controls now support a separate widget bucket: | |||
- `maxActiveRuns` | |||
- global shared cap for normal app AI traffic | |||
- `mediaWikiWidgetMaxActiveRuns` | |||
- dedicated cap for `sourceApp === "MediaWiki Widget"` | |||
Default widget capacity now falls back to: | |||
- `MEDIAWIKI_WIDGET_MAX_ACTIVE_RUNS=12` | |||
If widget traffic hits its own limit, the user will now get a widget-specific capacity message instead of competing directly with all other AI routes. | |||
## Where the source lives in NPC Forge | |||
Relevant app files: | |||
- [widget/page.tsx](/C:/Antigravity/npc-forge-app/src/app/widget/page.tsx) | |||
- [lorekeeper-widget.tsx](/C:/Antigravity/npc-forge-app/src/components/lorekeeper-widget.tsx) | |||
- [lore-page-client.tsx](/C:/Antigravity/npc-forge-app/src/components/lore-page-client.tsx) | |||
- [lore-widget.ts](/C:/Antigravity/npc-forge-app/src/lib/lore-widget.ts) | |||
- [ai-runtime-controls.service.ts](/C:/Antigravity/npc-forge-app/src/services/ai-runtime-controls.service.ts) | |||
- [askLore route.ts](/C:/Antigravity/npc-forge-app/src/app/api/askLore/route.ts) | |||
## Troubleshooting checklist | |||
If the widget breaks on the wiki: | |||
1. Check `User:Jono_Bean/common.js` | |||
- confirm the iframe URL is correct | |||
- confirm `parentOrigin` is being passed | |||
2. Check browser console on the wiki page | |||
- look for blocked iframe or CSP errors | |||
- look for failed message-origin checks | |||
3. Check widget network calls | |||
- `/api/askLore` | |||
- `/api/lore/stream` | |||
4. If errors mention capacity | |||
- inspect active `system_runs` | |||
- inspect `system_ai_leases` | |||
- compare global `maxActiveRuns` vs `mediaWikiWidgetMaxActiveRuns` | |||
5. If the widget background looks wrong | |||
- verify widget route transparency fixes are present | |||
- verify the iframe container itself does not set a non-transparent background | |||
## Readiness smoke | |||
Run the combined Discord/widget readiness gate before marking the widget code ready for testing: | |||
```text | |||
npm run discord-widget:readiness -- -- --write docs/discord-widget-readiness-YYYY-MM-DD.md --live | |||
``` | |||
This checks the `/widget` route contract, `parentOrigin`/`lorekeeper-resize` messaging, the Discord Cloud Tasks route contract, current env/runbook docs, typecheck, and a live hosted `/widget` HTML response. The stricter Discord endpoint check is separate and can be run after deployment with `--live-discord`. | |||
## Suggested next admin improvement | |||
Add an admin panel that shows both: | |||
- global active AI runs | |||
- MediaWiki widget active AI runs | |||
That would make it much easier to see whether the widget is saturated, whether a run is stale, and whether the global cap or widget cap is the real bottleneck. | |||
Revision as of 04:02, 26 May 2026
# DQ Media Wiki Lorekeeper Widget
This note documents how the DQ Media Wiki widget is loaded from MediaWiki, what the current `common.js` script does, how the iframe talks to NPC Forge, and what changed in the recent hardening work.
## Where the MediaWiki code lives
For Jono Bean, the active widget loader is currently in:
- `https://dq-nz.org/dqwiki/index.php/User:Jono_Bean/common.js`
This means the widget is user-scoped rather than site-wide. Only that logged-in user sees it unless the same code is copied into a site script such as `MediaWiki:Common.js` or a gadget.
## Current MediaWiki loader shape
The wiki-side script:
1. Reads the logged-in MediaWiki username with `mw.config.get('wgUserName')`
2. Detects whether the parent wiki viewport is mobile
3. Builds an iframe URL pointing at:
- `https://npc-forge--npc-forge-qh4gc.asia-southeast1.hosted.app/widget`
4. Passes either:
- `user=<wiki username>`
- or `guest=Traveler`
5. Passes `isMobile=true` when the wiki viewport is narrow
6. Creates a fixed-position iframe container in the bottom-right corner
7. Listens for `postMessage` events from the NPC Forge widget and resizes the iframe when the widget opens, closes, or changes layout
## Recommended MediaWiki `common.js`
This is the current recommended loader script for the DQ Media Wiki user script:
```javascript
/* Lorekeeper Widget Loader - v1.2.0 (MediaWiki polished loader) */
(function () {
'use strict';
var CONFIG = {
appOrigin: 'https://npc-forge--npc-forge-qh4gc.asia-southeast1.hosted.app',
widgetPath: '/widget',
containerId: 'lorekeeper-widget-container',
iframeId: 'lorekeeper-widget-frame',
mobileBreakpoint: 768,
edgeGap: 16,
desktopCollapsed: { width: 300, height: 120 },
mobileCollapsed: { width: 80, height: 80 },
desktopMax: { width: 720, height: 720 },
desktopMin: { width: 300, height: 120 },
zIndex: 99999,
debug: false
};
if (window.__lorekeeperWidgetLoader && window.__lorekeeperWidgetLoader.version) {
return;
}
var state = {
mounted: false,
resizeTimer: 0,
lastMode: null
};
function log() {
if (!CONFIG.debug || !window.console) return;
window.console.log.apply(window.console, ['[Lorekeeper Widget]'].concat([].slice.call(arguments)));
}
function isMobileViewport() {
return window.innerWidth < CONFIG.mobileBreakpoint;
}
function currentMode() {
return isMobileViewport() ? 'mobile' : 'desktop';
}
function getMediaWikiUsername() {
try {
if (!window.mw || !window.mw.config) return '';
return String(window.mw.config.get('wgUserName') || '').trim();
} catch (err) {
return '';
}
}
function buildWidgetUrl() {
var url = new URL(CONFIG.widgetPath, CONFIG.appOrigin);
var username = getMediaWikiUsername();
if (username) {
url.searchParams.set('user', username);
} else {
url.searchParams.set('guest', 'Traveler');
}
url.searchParams.set('isMobile', isMobileViewport() ? 'true' : 'false');
url.searchParams.set('parentOrigin', window.location.origin);
return url.toString();
}
function px(value) {
return Math.round(value) + 'px';
}
function clampNumber(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function normalizeSize(value, axis) {
if (typeof value === 'string') {
if (value === '100vw' || value === '100vh') return value;
var parsed = parseFloat(value);
if (!Number.isFinite(parsed)) return null;
value = parsed;
}
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
if (isMobileViewport()) {
return axis === 'width' ? '100vw' : '100vh';
}
var viewportLimit = axis === 'width'
? window.innerWidth - (CONFIG.edgeGap * 2)
: window.innerHeight - (CONFIG.edgeGap * 2);
var min = CONFIG.desktopMin[axis];
var max = Math.min(CONFIG.desktopMax[axis], viewportLimit);
return px(clampNumber(value, min, Math.max(min, max)));
}
function setStyles(element, styles) {
Object.keys(styles).forEach(function (key) {
element.style[key] = styles[key];
});
}
function setCollapsedSize(container) {
var size = isMobileViewport() ? CONFIG.mobileCollapsed : CONFIG.desktopCollapsed;
setStyles(container, {
width: px(size.width),
height: px(size.height)
});
}
function createContainer() {
var container = document.createElement('div');
container.id = CONFIG.containerId;
container.setAttribute('data-lorekeeper-widget', 'true');
setStyles(container, {
position: 'fixed',
right: isMobileViewport() ? '0' : px(CONFIG.edgeGap),
bottom: isMobileViewport() ? '0' : px(CONFIG.edgeGap),
zIndex: String(CONFIG.zIndex),
pointerEvents: 'none',
overflow: 'hidden',
background: 'transparent',
border: '0',
padding: '0',
margin: '0',
maxWidth: '100vw',
maxHeight: '100vh',
transition: window.matchMedia('(prefers-reduced-motion: reduce)').matches
? 'none'
: 'width 180ms ease, height 180ms ease'
});
setCollapsedSize(container);
return container;
}
function createIframe() {
var iframe = document.createElement('iframe');
iframe.id = CONFIG.iframeId;
iframe.title = 'NPC Forge Lorekeeper';
iframe.src = buildWidgetUrl();
iframe.setAttribute('allow', 'clipboard-write');
iframe.setAttribute('loading', 'lazy');
iframe.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin');
iframe.setAttribute('data-mode', currentMode());
setStyles(iframe, {
display: 'block',
width: '100%',
height: '100%',
border: '0',
padding: '0',
margin: '0',
background: 'transparent',
colorScheme: 'normal',
pointerEvents: 'auto'
});
return iframe;
}
function ensureMounted() {
if (!document.body) return false;
var container = document.getElementById(CONFIG.containerId);
var iframe = document.getElementById(CONFIG.iframeId);
if (!container) {
container = createContainer();
}
if (!iframe) {
iframe = createIframe();
}
if (iframe.parentNode !== container) {
container.appendChild(iframe);
}
if (container.parentNode !== document.body) {
document.body.appendChild(container);
}
state.lastMode = currentMode();
state.mounted = true;
log('mounted', state.lastMode);
return true;
}
function applyResize(width, height) {
var container = document.getElementById(CONFIG.containerId);
if (!container) return;
var nextWidth = normalizeSize(width, 'width');
var nextHeight = normalizeSize(height, 'height');
if (nextWidth) container.style.width = nextWidth;
if (nextHeight) container.style.height = nextHeight;
container.style.right = isMobileViewport() ? '0' : px(CONFIG.edgeGap);
container.style.bottom = isMobileViewport() ? '0' : px(CONFIG.edgeGap);
}
function handleMessage(event) {
var iframe = document.getElementById(CONFIG.iframeId);
if (event.origin !== CONFIG.appOrigin) return;
if (iframe && event.source !== iframe.contentWindow) return;
if (!event.data || event.data.type !== 'lorekeeper-resize') return;
applyResize(event.data.width, event.data.height);
}
function refreshForViewport() {
var container = document.getElementById(CONFIG.containerId);
var iframe = document.getElementById(CONFIG.iframeId);
if (!container || !iframe) return;
var nextMode = currentMode();
container.style.right = nextMode === 'mobile' ? '0' : px(CONFIG.edgeGap);
container.style.bottom = nextMode === 'mobile' ? '0' : px(CONFIG.edgeGap);
if (nextMode !== state.lastMode) {
state.lastMode = nextMode;
iframe.setAttribute('data-mode', nextMode);
iframe.src = buildWidgetUrl();
setCollapsedSize(container);
log('mode changed', nextMode);
return;
}
if (container.style.width === '100vw' || container.style.height === '100vh') {
applyResize(container.style.width, container.style.height);
}
}
function handleWindowResize() {
window.clearTimeout(state.resizeTimer);
state.resizeTimer = window.setTimeout(refreshForViewport, 120);
}
function destroy() {
window.clearTimeout(state.resizeTimer);
window.removeEventListener('message', handleMessage, false);
window.removeEventListener('resize', handleWindowResize, false);
var container = document.getElementById(CONFIG.containerId);
if (container && container.parentNode) {
container.parentNode.removeChild(container);
}
state.mounted = false;
window.__lorekeeperWidgetLoader = null;
}
function init() {
if (!ensureMounted()) {
window.setTimeout(init, 50);
return;
}
window.addEventListener('message', handleMessage, false);
window.addEventListener('resize', handleWindowResize, false);
window.__lorekeeperWidgetLoader = {
version: '1.2.0',
refresh: refreshForViewport,
destroy: destroy
};
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();
```
## What the app expects from MediaWiki
The widget route now expects these query params from the wiki loader:
- `user`
- preferred when MediaWiki has a logged-in user
- `guest`
- fallback display/storage identity when no logged-in user exists
- `isMobile=true`
- tells the iframe to start in mobile layout
- `parentOrigin`
- used by the iframe so resize messages can target the wiki origin more safely
The widget app normalizes `user` and `guest` before using them for local guest storage and the header badge.
## Iframe message contract
The NPC Forge widget sends this message back to the parent page:
```json
{
"type": "lorekeeper-resize",
"width": 700,
"height": 650
}
```
Or on mobile:
```json
{
"type": "lorekeeper-resize",
"width": "100vw",
"height": "100vh"
}
```
The wiki loader should:
- check `event.origin`
- check `event.data.type === 'lorekeeper-resize'`
- apply the new width and height to the fixed widget container
## Recent widget fixes
The NPC Forge side was recently hardened in these areas:
1. Transparent embed shell
- removed the large grey iframe background slab behind the Lorekeeper card
2. Safer widget identity handling
- `user` and `guest` values are trimmed, normalized, and bounded before use
3. Better widget API routing
- external hosts like the DQ Media Wiki now resolve the correct absolute API base more safely
- local/private hosts no longer get mistaken for production external embeds
4. Safer widget messaging
- `parentOrigin` is supported for stricter `postMessage` targeting
5. Cleaner widget error reporting
- widget requests no longer retry the same endpoint and then misreport the failure as a fake fallback error
6. Polished MediaWiki loader paste script
- v1.2.0 keeps all tweakable values in one `CONFIG` block
- uses `URL` / `searchParams` instead of hand-built query strings
- verifies both message origin and iframe message source before resizing
- clamps desktop resize requests to the viewport so the widget cannot sprawl off-screen
- debounces viewport resize handling and reloads the iframe only when crossing the mobile breakpoint
- exposes `window.__lorekeeperWidgetLoader.refresh()` and `.destroy()` for live wiki debugging
## Capacity handling
Originally, the MediaWiki widget was competing against the global AI cap used by all app features. That meant a low setting like `2` active runs could make the wiki widget fail even when only a couple of unrelated AI jobs were running elsewhere.
The runtime controls now support a separate widget bucket:
- `maxActiveRuns`
- global shared cap for normal app AI traffic
- `mediaWikiWidgetMaxActiveRuns`
- dedicated cap for `sourceApp === "MediaWiki Widget"`
Default widget capacity now falls back to:
- `MEDIAWIKI_WIDGET_MAX_ACTIVE_RUNS=12`
If widget traffic hits its own limit, the user will now get a widget-specific capacity message instead of competing directly with all other AI routes.
## Where the source lives in NPC Forge
Relevant app files:
- [widget/page.tsx](/C:/Antigravity/npc-forge-app/src/app/widget/page.tsx)
- [lorekeeper-widget.tsx](/C:/Antigravity/npc-forge-app/src/components/lorekeeper-widget.tsx)
- [lore-page-client.tsx](/C:/Antigravity/npc-forge-app/src/components/lore-page-client.tsx)
- [lore-widget.ts](/C:/Antigravity/npc-forge-app/src/lib/lore-widget.ts)
- [ai-runtime-controls.service.ts](/C:/Antigravity/npc-forge-app/src/services/ai-runtime-controls.service.ts)
- [askLore route.ts](/C:/Antigravity/npc-forge-app/src/app/api/askLore/route.ts)
## Troubleshooting checklist
If the widget breaks on the wiki:
1. Check `User:Jono_Bean/common.js`
- confirm the iframe URL is correct
- confirm `parentOrigin` is being passed
2. Check browser console on the wiki page
- look for blocked iframe or CSP errors
- look for failed message-origin checks
3. Check widget network calls
- `/api/askLore`
- `/api/lore/stream`
4. If errors mention capacity
- inspect active `system_runs`
- inspect `system_ai_leases`
- compare global `maxActiveRuns` vs `mediaWikiWidgetMaxActiveRuns`
5. If the widget background looks wrong
- verify widget route transparency fixes are present
- verify the iframe container itself does not set a non-transparent background
## Readiness smoke
Run the combined Discord/widget readiness gate before marking the widget code ready for testing:
```text
npm run discord-widget:readiness -- -- --write docs/discord-widget-readiness-YYYY-MM-DD.md --live
```
This checks the `/widget` route contract, `parentOrigin`/`lorekeeper-resize` messaging, the Discord Cloud Tasks route contract, current env/runbook docs, typecheck, and a live hosted `/widget` HTML response. The stricter Discord endpoint check is separate and can be run after deployment with `--live-discord`.
## Suggested next admin improvement
Add an admin panel that shows both:
- global active AI runs
- MediaWiki widget active AI runs
That would make it much easier to see whether the widget is saturated, whether a run is stale, and whether the global cap or widget cap is the real bottleneck.