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.1.4 (MediaWiki Hardened Loader) */
# 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 () {
     if (window.__lorekeeperWidgetLoaded) return;
     'use strict';
    window.__lorekeeperWidgetLoaded = true;


     var APP_ORIGIN = 'https://npc-forge--npc-forge-qh4gc.asia-southeast1.hosted.app';
     var CONFIG = {
    var APP_WIDGET_URL = APP_ORIGIN + '/widget';
        appOrigin: 'https://npc-forge--npc-forge-qh4gc.asia-southeast1.hosted.app',
    var MOBILE_BREAKPOINT = 768;
        widgetPath: '/widget',
    var CONTAINER_ID = 'lorekeeper-widget-container';
        containerId: 'lorekeeper-widget-container',
    var IFRAME_ID = 'lorekeeper-widget-frame';
        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 < MOBILE_BREAKPOINT;
         return window.innerWidth < CONFIG.mobileBreakpoint;
    }
 
    function currentMode() {
        return isMobileViewport() ? 'mobile' : 'desktop';
     }
     }


     function getUsername() {
     function getMediaWikiUsername() {
         try {
         try {
             return (mw && mw.config && mw.config.get('wgUserName')) || '';
             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 buildIframeUrl() {
     function buildWidgetUrl() {
         var params = [];
         var url = new URL(CONFIG.widgetPath, CONFIG.appOrigin);
         var username = getUsername();
         var username = getMediaWikiUsername();


         if (username) {
         if (username) {
             params.push('user=' + encodeURIComponent(username));
             url.searchParams.set('user', username);
         } else {
         } else {
             params.push('guest=' + encodeURIComponent('Traveler'));
             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()) {
             params.push('isMobile=true');
             return axis === 'width' ? '100vw' : '100vh';
         }
         }


         params.push('parentOrigin=' + encodeURIComponent(window.location.origin));
         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)));
    }


         return APP_WIDGET_URL + '?' + params.join('&');
    function setStyles(element, styles) {
         Object.keys(styles).forEach(function (key) {
            element.style[key] = styles[key];
        });
     }
     }


     function setCollapsedSize(container) {
     function setCollapsedSize(container) {
         if (isMobileViewport()) {
         var size = isMobileViewport() ? CONFIG.mobileCollapsed : CONFIG.desktopCollapsed;
            container.style.width = '80px';
        setStyles(container, {
            container.style.height = '80px';
             width: px(size.width),
        } else {
             height: px(size.height)
             container.style.width = '300px';
         });
             container.style.height = '120px';
         }
     }
     }


     function ensureContainer() {
     function createContainer() {
         var existing = document.getElementById(CONTAINER_ID);
         var container = document.createElement('div');
         if (existing) return existing;
         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'
        });


        var container = document.createElement('div');
        container.id = CONTAINER_ID;
        container.style.position = 'fixed';
        container.style.bottom = '0';
        container.style.right = '0';
        container.style.zIndex = '99999';
        container.style.pointerEvents = 'none';
        container.style.overflow = 'hidden';
        container.style.background = 'transparent';
        container.style.transition = 'width 0.3s ease, height 0.3s ease';
         setCollapsedSize(container);
         setCollapsedSize(container);
         return container;
         return container;
     }
     }


     function ensureIframe() {
     function createIframe() {
        var existing = document.getElementById(IFRAME_ID);
        if (existing) return existing;
 
         var iframe = document.createElement('iframe');
         var iframe = document.createElement('iframe');
         iframe.id = IFRAME_ID;
         iframe.id = CONFIG.iframeId;
        iframe.src = buildIframeUrl();
         iframe.title = 'NPC Forge Lorekeeper';
         iframe.style.width = '100%';
         iframe.src = buildWidgetUrl();
         iframe.style.height = '100%';
        iframe.style.border = 'none';
        iframe.style.background = 'transparent';
        iframe.style.pointerEvents = 'auto';
         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 mountWidget() {
     function ensureMounted() {
         if (!document.body) return false;
         if (!document.body) return false;


         var container = ensureContainer();
         var container = document.getElementById(CONFIG.containerId);
         var iframe = ensureIframe();
         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);
        } else if (!iframe.parentNode) {
            container.appendChild(iframe);
         }
         }


        state.lastMode = currentMode();
        state.mounted = true;
        log('mounted', state.lastMode);
         return true;
         return true;
     }
     }


     function applyResize(container, width, height) {
     function applyResize(width, height) {
        var container = document.getElementById(CONFIG.containerId);
         if (!container) return;
         if (!container) return;


         if (typeof width === 'number') {
         var nextWidth = normalizeSize(width, 'width');
            container.style.width = width + 'px';
        var nextHeight = normalizeSize(height, 'height');
         } else if (typeof width === 'string' && width) {
 
            container.style.width = width;
        if (nextWidth) container.style.width = nextWidth;
        }
         if (nextHeight) container.style.height = nextHeight;


         if (typeof height === 'number') {
         container.style.right = isMobileViewport() ? '0' : px(CONFIG.edgeGap);
            container.style.height = height + 'px';
         container.style.bottom = isMobileViewport() ? '0' : px(CONFIG.edgeGap);
         } else if (typeof height === 'string' && height) {
            container.style.height = height;
        }
     }
     }


     function handleMessage(event) {
     function handleMessage(event) {
         if (event.origin !== APP_ORIGIN) return;
        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;


        var container = document.getElementById(CONTAINER_ID);
         applyResize(event.data.width, event.data.height);
        if (!container) return;
 
         applyResize(container, event.data.width, event.data.height);
     }
     }


     function handleWindowResize() {
     function refreshForViewport() {
         var container = document.getElementById(CONTAINER_ID);
         var container = document.getElementById(CONFIG.containerId);
         var iframe = document.getElementById(IFRAME_ID);
         var iframe = document.getElementById(CONFIG.iframeId);
         if (!container || !iframe) return;
         if (!container || !iframe) return;


         var wasMobile = iframe.getAttribute('data-is-mobile') === 'true';
         var nextMode = currentMode();
         var nowMobile = isMobileViewport();
        container.style.right = nextMode === 'mobile' ? '0' : px(CONFIG.edgeGap);
         container.style.bottom = nextMode === 'mobile' ? '0' : px(CONFIG.edgeGap);


         if (wasMobile !== nowMobile) {
         if (nextMode !== state.lastMode) {
             iframe.setAttribute('data-is-mobile', nowMobile ? 'true' : 'false');
            state.lastMode = nextMode;
             iframe.src = buildIframeUrl();
             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') {
            container.style.width === '80px' ||
             applyResize(container.style.width, container.style.height);
            container.style.width === '300px' ||
        }
             container.style.width === '100vw'
    }
         ) {
 
             setCollapsedSize(container);
    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 (!mountWidget()) {
         if (!ensureMounted()) {
             window.setTimeout(init, 50);
             window.setTimeout(init, 50);
             return;
             return;
        }
        var iframe = document.getElementById(IFRAME_ID);
        if (iframe) {
            iframe.setAttribute('data-is-mobile', isMobileViewport() ? 'true' : 'false');
         }
         }


         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.