Jump to content

User:Wilfredor/commons-nominator.js

From Wikimedia Commons, the free media repository
Note: After saving, you have to bypass your browser's cache to see the changes. Internet Explorer: press Ctrl-F5, Mozilla: hold down Shift while clicking Reload (or press Ctrl-Shift-R), Opera/Konqueror: press F5, Safari: hold down Shift + Alt while clicking Reload, Chrome: hold down Shift while clicking Reload.
// [[Category:User scripts by Wilfredor|commons-nominator]] <nowiki>
/*
 * Commons Nominator, Wilfredor
 *
 * Adds "Nominate for…" links on Commons File: pages for the three
 * image-quality processes, and files the nomination with ONE edit per
 * explicit click (strictly human-paced, nothing is written without a click):
 *
 *   β€’ 🌟 Featured Picture (FPC), creates the nomination subpage and
 *       transcludes it at the top of the candidate list. Enforces the
 *       2-active-nomination cap (rule 11).  [extracted from fpc-archiver.js]
 *   β€’ πŸ…€ Quality Image (QIC), adds one gallery line under today's UTC
 *       date heading on the QIC candidate list. Enforces the 5-per-day cap.
 *   β€’ β“‹ Valued Image (VIC), creates the nomination subpage and appends
 *       the file to the \u007b{VICs}} list. No daily/open cap (per-scope uniqueness
 *       is a reviewer judgement, surfaced as a reminder).
 *
 * The FP nomination flow (button + dialog + gallery picker + submit) was moved
 * out of fpc-archiver.js verbatim; the archiver is now the closer + EXIF panel
 * only. The deep image-quality engine (camera DB, diffraction, sharpness) is
 * NOT duplicated here, the dialog shows lightweight, objective hard-rule
 * checks only.
 *
 * Install: add to Special:MyPage/common.js
 *     mw.loader.load('/w/index.php?title=User:Wilfredor/commons-nominator.js&action=raw&ctype=text/javascript');
 */
( function () {
    'use strict';

    var ns = mw.config.get( 'wgNamespaceNumber' );
    var page = mw.config.get( 'wgPageName' ) || '';
    var isFilePage = ns === 6 && /^File:/.test( page );
    // FPC nomination-subpage editor, the rule-11 banner guard (moved from
    // fpc-archiver.js). Fires when the user manually opens the editor for a
    // brand-new FPC nomination subpage (File:/Set/removal variants).
    var isFpcEditPage = /^Commons:Featured[ _]picture[ _]candidates\/(File:|Set\/|removal\/)/.test( page );
    var isFpcNomPage = /^Commons:Featured[ _]picture[ _]candidates\/File:/.test( page );
    var isFpcPage = /^Commons:Featured[ _]picture[ _]candidates(?:\/|$)/.test( page );
    var isCategoryPage = ns === 14;
    if ( !isFilePage && !isFpcPage && !isCategoryPage ) return;

    // Canonical page titles used across the three processes.
    var QIC_LIST = 'Commons:Quality images candidates/candidate list';
    var VIC_LIST = 'Commons:Valued image candidates/candidate list';
    var VIC_SUBPAGE_PREFIX = 'Commons:Valued image candidates/';
    var MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June',
        'July', 'August', 'September', 'October', 'November', 'December' ];

    // Shared cache for the FP gallery icon map (also the catalog of every known
    // leaf gallery name). Populated lazily on the first FP-nominate dialog open.
    var fpgIconMapPromise = null;

    mw.loader.using( [ 'mediawiki.api', 'mediawiki.notification', 'mediawiki.util', 'mediawiki.ForeignApi' ] ).done( function () {
        injectStyles();
        var api = new mw.Api();
        if ( isFilePage ) {
            setupNominateButtons( api );
        } else if ( isFpcPage && mw.config.get( 'wgAction' ) === 'view' ) {
            setupFpcRenameButtons( api );
        } else if ( isFpcEditPage ) {
            checkActiveNomLimitOnEdit( api );
        } else if ( isCategoryPage ) {
            setupCategoryActions( api );
        }
    } );

    // Dispatcher: insert all three nominate links on a File: page. Each link
    // runs its own precheck + dialog; they share helpers but never auto-fire.
    function setupNominateButtons( api ) {
        setupNominateButton( api );   // 🌟 FP  (full dialog: gallery + samples)
        setupQicNominateLink( api );  // πŸ…€ QIC (≀5/day guard)
        setupVicNominateLink( api );  // β“‹ VIC (subpage + \u007b{VICs}} append)
        setupPotdLink( api );         // πŸ—“ POTD (FP never POTD β†’ next free date)
        setupWikidataImageLink( api );// πŸ–Ό set this FP as its depicted item's Wikidata image (P18)
        setupSuggestArticlesLink( api ); // πŸ”Ž find en.wp articles this FP could illustrate
    }

    function normalizeFileTitle( value ) {
        value = String( value || '' ).replace( /_/g, ' ' ).trim();
        value = value.replace( /^\s*(?:File|Image)\s*:\s*/i, '' ).replace( /\s+/g, ' ' );
        return value ? 'File:' + value : '';
    }

    function titleCore( title ) {
        return String( title || '' ).replace( /^(?:File|Image):/i, '' );
    }

    function mwTitlePattern( title ) {
        return String( title || '' ).replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ).replace( /[ _]+/g, '[ _]+' );
    }

    function replaceFpcFileRefs( wt, oldFile, newFile ) {
        var oldCore = titleCore( oldFile );
        var newCore = titleCore( newFile );
        var linkRe = new RegExp( '(\\[\\[\\s*:?\\s*)(File|Image)(\\s*:\\s*)' +
            mwTitlePattern( oldCore ) + '(?=([\\]|#]))', 'gi' );
        wt = wt.replace( linkRe, function ( m, open, nsText, colon ) {
            return open + 'File' + colon + newCore;
        } );
        [
            'File:' + oldCore,
            'Image:' + oldCore,
            'File:' + oldCore.replace( / /g, '_' ),
            'Image:' + oldCore.replace( / /g, '_' )
        ].sort( function ( a, b ) { return b.length - a.length; } ).forEach( function ( old ) {
            wt = wt.split( old ).join( 'File:' + newCore );
        } );
        wt = wt.split( oldCore ).join( newCore );
        wt = wt.split( oldCore.replace( / /g, '_' ) ).join( newCore.replace( / /g, '_' ) );
        return wt;
    }

    function replaceFpcNomPageRefs( wt, oldNom, newNom ) {
        return wt.replace( new RegExp( mwTitlePattern( oldNom ), 'gi' ), newNom );
    }

    function currentFpcNomParts() {
        var current = ( mw.config.get( 'wgPageName' ) || '' ).replace( /_/g, ' ' );
        var m = current.match( /^Commons:Featured picture candidates\/(File:.+?)(\/\d+)?$/ );
        if ( !m ) return null;
        return {
            oldNom: current,
            oldFile: normalizeFileTitle( m[ 1 ] ),
            suffix: m[ 2 ] || ''
        };
    }

    function fpcNomPartsFromTitle( nomPage ) {
        nomPage = String( nomPage || '' ).replace( /_/g, ' ' );
        var m = nomPage.match( /^Commons:Featured picture candidates\/(File:.+?)(\/\d+)?$/ );
        if ( !m ) return null;
        return {
            oldNom: nomPage,
            oldFile: normalizeFileTitle( m[ 1 ] ),
            suffix: m[ 2 ] || ''
        };
    }

    function headingNomPageForRename( head ) {
        var editLink = head && head.querySelector && head.querySelector( '.mw-editsection a[href*="title="]' );
        if ( editLink ) {
            try {
                var url = new URL( editLink.href, location.href );
                var title = ( url.searchParams.get( 'title' ) || '' ).replace( /_/g, ' ' );
                if ( /^Commons:Featured picture candidates\/File:/.test( title ) ) return title;
            } catch ( e ) {}
        }
        var fileLink = head && head.querySelector && head.querySelector( 'a[href*="/wiki/File:"]' );
        if ( fileLink ) {
            var raw = fileLink.getAttribute( 'href' ) || '';
            try { raw = decodeURIComponent( raw.replace( /^\/wiki\//, '' ) ).replace( /_/g, ' ' ); } catch ( e2 ) {}
            if ( /^File:/.test( raw ) ) return 'Commons:Featured picture candidates/' + raw;
        }
        return null;
    }

    function moveWikiPage( oldTitle, newTitle, reason, api ) {
        return apiWriteWithMaxlagRetry( function () {
            return api.postWithToken( 'csrf', {
                action: 'move',
                from: oldTitle,
                to: newTitle,
                reason: reason,
                movetalk: 1,
                maxlag: 5,
                assert: 'user',
                formatversion: 2
            } );
        } );
    }

    function editWikiPage( title, newText, summary, api ) {
        return editWikiPageTransform( title, function () { return newText; }, summary, api );
    }

    function editWikiPageTransform( title, transform, summary, api ) {
        return fetchPage( title, api ).then( function ( pageData ) {
            if ( pageData.missing ) throw new Error( 'Missing page: ' + title );
            var newText = transform( pageData.wt );
            if ( pageData.wt === newText ) return { skipped: true };
            return apiWriteWithMaxlagRetry( function () {
                return api.postWithToken( 'csrf', {
                    action: 'edit',
                    title: title,
                    text: newText,
                    summary: summary,
                    baserevid: pageData.baserevid,
                    basetimestamp: pageData.basetimestamp,
                    maxlag: 5,
                    nocreate: 1,
                    assert: 'user',
                    formatversion: 2
                } );
            }, title );
        } );
    }

    function apiWriteWithMaxlagRetry( makeRequest, label ) {
        var waited = 0;
        function run() {
            return Promise.resolve( makeRequest() ).catch( function ( err ) {
                var code = err && ( err.code || err.error && err.error.code ) || err;
                var lag = parseFloat( err && ( err.lag || err.error && err.error.lag ) || 0 );
                if ( code !== 'maxlag' ) throw err;
                if ( waited >= 300 ) throw err;
                var wait = Math.max( 5, Math.min( lag || 5, 60 ) );
                waited += wait;
                try { console.warn( '[commons-nominator] maxlag while writing ' + ( label || 'page' ) + '; retrying in ' + wait + 's' ); } catch ( e ) {}
                return new Promise( function ( resolve ) {
                    setTimeout( resolve, wait * 1000 );
                } ).then( run );
            } );
        }
        return run();
    }

    function addFpcRenameButton( heading, parts, api ) {
        if ( !heading || !parts || heading.querySelector( '.cn-fpc-rename-btn' ) ) return;
        var btn = document.createElement( 'button' );
        btn.className = 'cn-fpc-rename-btn';
        btn.type = 'button';
        btn.textContent = 'Rename';
        btn.title = 'Rename the file and keep this FPC nomination in sync';
        btn.addEventListener( 'click', function () {
            showFpcRenameDialog( parts, api );
        } );
        heading.appendChild( btn );
    }

    function setupFpcRenameButtons( api ) {
        var currentParts = currentFpcNomParts();
        if ( currentParts ) addFpcRenameButton( document.getElementById( 'firstHeading' ), currentParts, api );
        document.querySelectorAll( '.mw-heading' ).forEach( function ( head ) {
            var nomPage = headingNomPageForRename( head );
            addFpcRenameButton( head, fpcNomPartsFromTitle( nomPage ), api );
        } );
    }

    function showFpcRenameDialog( parts, api ) {
        var modal = buildModal( 'Rename FPC file' );
        modal.submitBtn.textContent = 'Rename';
        var oldCore = titleCore( parts.oldFile );
        modal.body.innerHTML =
            '<section class="cn-rename-form">' +
            '  <label>Current file<input class="cn-rename-old" type="text" readonly></label>' +
            '  <label>New file name<input class="cn-rename-new" type="text" autocomplete="off"></label>' +
            '  <label>Reason<input class="cn-rename-reason" type="text" autocomplete="off"></label>' +
            '  <div class="cn-rename-plan"></div>' +
            '</section>';
        var oldInput = modal.body.querySelector( '.cn-rename-old' );
        var newInput = modal.body.querySelector( '.cn-rename-new' );
        var reasonInput = modal.body.querySelector( '.cn-rename-reason' );
        var planEl = modal.body.querySelector( '.cn-rename-plan' );
        oldInput.value = parts.oldFile;
        newInput.value = oldCore;
        reasonInput.value = 'more descriptive file name';
        newInput.select();

        function renderPlan() {
            var newFile = normalizeFileTitle( newInput.value );
            var valid = newFile && newFile !== parts.oldFile;
            modal.submitBtn.disabled = !valid;
            if ( !valid ) {
                planEl.textContent = 'Enter a different file name.';
                return;
            }
            var newNom = 'Commons:Featured picture candidates/' + newFile + parts.suffix;
            planEl.innerHTML =
                '<b>Will update:</b>' +
                '<ul>' +
                '<li>' + escapeHtml( parts.oldFile ) + ' -> ' + escapeHtml( newFile ) + '</li>' +
                '<li>' + escapeHtml( parts.oldNom ) + ' -> ' + escapeHtml( newNom ) + '</li>' +
                '<li>Nomination body and FPC candidate list transclusion</li>' +
                '</ul>';
        }
        newInput.addEventListener( 'input', renderPlan );
        renderPlan();

        modal.submitBtn.addEventListener( 'click', function () {
            var newFile = normalizeFileTitle( newInput.value );
            var newNom = 'Commons:Featured picture candidates/' + newFile + parts.suffix;
            var reason = reasonInput.value.trim() || 'more descriptive file name';
            var moveSummary = reason + '; keep FPC nomination in sync';
            var editSummary = 'Update FPC nomination after file rename: [[' + parts.oldFile + ']] -> [[' + newFile + ']]';
            modal.submitBtn.disabled = true;
            modal.cancelBtn.disabled = true;
            modal.status.textContent = 'Reading pages...';
            Promise.all( [
                fetchPage( parts.oldNom, api ),
                fetchPage( 'Commons:Featured picture candidates/candidate list', api ),
                fetchPage( newFile, api ),
                fetchPage( newNom, api )
            ] ).then( function ( reads ) {
                if ( reads[ 0 ].missing ) throw new Error( 'The nomination page is missing.' );
                if ( reads[ 1 ].missing ) throw new Error( 'The FPC candidate list is missing.' );
                if ( !reads[ 2 ].missing ) throw new Error( 'The target file already exists: ' + newFile );
                if ( !reads[ 3 ].missing ) throw new Error( 'The target nomination page already exists: ' + newNom );
                modal.status.textContent = 'Moving file...';
                return moveWikiPage( parts.oldFile, newFile, moveSummary, api ).then( function () {
                    modal.status.textContent = 'Moving nomination...';
                    return moveWikiPage( parts.oldNom, newNom, moveSummary, api );
                } ).then( function () {
                    modal.status.textContent = 'Updating nomination body...';
                    return editWikiPageTransform( newNom, function ( wt ) {
                        return replaceFpcFileRefs( wt, parts.oldFile, newFile );
                    }, editSummary, api );
                } ).then( function () {
                    modal.status.textContent = 'Updating candidate list...';
                    return editWikiPageTransform( 'Commons:Featured picture candidates/candidate list', function ( wt ) {
                        return replaceFpcNomPageRefs( wt, parts.oldNom, newNom );
                    }, editSummary, api );
                } ).then( function () {
                    modal.status.textContent = 'Done. Opening renamed nomination...';
                    setTimeout( function () {
                        window.location.href = '/wiki/' + encodeURIComponent( newNom.replace( / /g, '_' ) );
                    }, 500 );
                } );
            } ).catch( function ( err ) {
                modal.submitBtn.disabled = false;
                modal.cancelBtn.disabled = false;
                modal.status.innerHTML = '<span style="color:#b91c1c">' + escapeHtml( err && err.message || err ) + '</span>';
            } );
        } );
    }

    // File page: for a FP not yet featured on en.wp, offer article discovery.
    function setupSuggestArticlesLink( api ) {
        var pageName = mw.config.get( 'wgPageName' ) || '';
        if ( !/^File:/.test( pageName ) ) return;
        var link = addFileActionLink( 'pt-enfp-suggest', 'πŸ”Ž Find en.wp articles',
            'List English Wikipedia articles where this featured picture could be placed' );
        if ( !link ) return;
        var li = link.closest( 'li' ) || link;
        li.style.display = 'none'; // only a FP that is not already an en.wp FP qualifies
        Promise.all( [ checkFileAssessments( pageName, api ), fetchPage( pageName, api ) ] ).then( function ( r ) {
            var a = r[ 0 ], wt = ( r[ 1 ] && r[ 1 ].wt ) || '';
            if ( a.isFP && !isAlreadyEnFp( wt ) ) li.style.display = '';
        } ).catch( function () {} );
        link.addEventListener( 'click', function ( e ) { e.preventDefault(); showSuggestArticlesDialog( pageName, api ); } );
    }

    // ─── Shared helpers (copied verbatim from fpc-archiver.js) ───────────────
    function escapeAttr( s ) {
        return String( s || '' ).replace( /"/g, '&quot;' ).replace( /</g, '&lt;' ).replace( />/g, '&gt;' );
    }

    function escapeHtml( s ) {
        return String( s || '' ).replace( /&/g, '&amp;' ).replace( /</g, '&lt;' ).replace( />/g, '&gt;' )
            .replace( /"/g, '&quot;' ).replace( /'/g, '&#39;' );
    }

    function canonUser( s ) {
        s = ( s || '' ).replace( /_/g, ' ' ).trim();
        if ( !s ) return '';
        return s.charAt( 0 ).toUpperCase() + s.slice( 1 );
    }

    function detectUploader( wt ) {
        // The nominator is the user whose signature is closest to (immediately
        // before) the FIRST UTC timestamp in the wikitext, that signature is
        // the one the nominator added when creating the nomination.
        // Common patterns:
        //   *\u007b{Info}} … Created by [[User:Creator]] – nominated by --[[User:Nominator]] (talk) 12:00, 1 Jan 2026 (UTC)
        //   *\u007b{Info}} … My photo. --[[User:Nominator]] 12:00, 1 Jan 2026 (UTC)
        var tsRe = /\d{1,2}:\d{2},\s+\d{1,2}\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{4}\s*\(UTC\)/;
        var tsMatch = wt.match( tsRe );
        if ( !tsMatch ) {
            // Fallback to first User: anywhere.
            var fallback = wt.match( /\[\[User:([^\]|]+)/ );
            return fallback ? fallback[ 1 ].trim() : '';
        }
        var tsPos = tsMatch.index;
        var preStart = Math.max( 0, tsPos - 300 );
        var pre = wt.slice( preStart, tsPos );
        var lastUser = null;
        var re = /\[\[\s*:?\s*(?:User(?:[ _]talk)?|Special[ _]?:?[ _]?Contributions)\s*[:/]\s*([^\]|]+?)\s*(?:\|[^\]]*)?\]\]/gi;
        var m;
        while ( ( m = re.exec( pre ) ) !== null ) {
            var target = m[ 1 ].trim();
            var pgm = target.match( /^\{\{\s*(?:PAGENAME|ucfirst|lc|uc)\s*:\s*([^}]+?)\s*\}\}$/i );
            if ( pgm ) target = pgm[ 1 ].trim();
            target = target.replace( /[#/].*$/, '' ).trim();
            if ( target ) lastUser = canonUser( target );
        }
        return lastUser || '';
    }

    var FP_GALLERY_RULES = [
        // ── Animalia (most specific subcategories first) ────────────────
        { re: /\b(birds?|aves|owls?|raptors?|eagles?|hawks?|parrots?|songbirds?|hummingbirds?|finches?|waterfowl|seabirds?)\b/i, gallery: 'Animals/Birds' },
        { re: /\b(mammals?|cetaceans?|whales?|dolphins?|seals?|primates?|cats?|dogs?|wolves|bears?|rodents?|bats?|deer|elephants?|horses?)\b/i, gallery: 'Animals/Mammals' },
        { re: /\b(reptiles?|snakes?|lizards?|turtles?|tortoises?|crocodiles?|alligators?)\b/i, gallery: 'Animals/Reptiles' },
        { re: /\b(amphibians?|frogs?|toads?|salamanders?|newts?)\b/i, gallery: 'Animals/Amphibians' },
        { re: /\b(fish(?:es)?|sharks?|rays|seahorses?|eels?|cichlids?)\b/i, gallery: 'Animals/Fish' },
        { re: /\b(insects?|butterflies|moths?|beetles?|bees?|wasps?|ants?|dragonflies|spiders?|arachnids?|crustaceans?|crabs?|lobsters?|shrimps?)\b/i, gallery: 'Animals/Arthropods' },
        { re: /\b(molluscs?|mollusks?|cephalopods?|octopu(?:s|ses)|squids?|cuttlefish|snails?|nudibranchs?|jellyfish|cnidarians?|corals?|sea anemones?)\b/i, gallery: 'Animals' },
        { re: /\b(animals?|fauna|wildlife|zoo|aquarium)\b/i, gallery: 'Animals' },
        // ── Plants / Mycology ───────────────────────────────────────────
        { re: /\b(fungi|mushrooms?|lichens?|slime molds?)\b/i, gallery: 'Fungi' },
        { re: /\b(plants?|flowers?|trees?|shrubs?|herbs?|grasses|ferns?|mosses?|botanic|botanical|orchids?|cacti|cactus|rosaceae|asteraceae|orchidaceae|fabaceae|family\s*:?\s*[A-Z][a-z]+aceae)\b/i, gallery: 'Plants' },
        // ── Places: split by sub-type, then country fall-back ───────────
        { re: /\b(aerial photographs?|aerial views?|aerial photography|from (?:above|the air)|drone (?:photos?|shots?|views?))\b/i, gallery: 'Places/Satellite images' },
        { re: /\b(astronomy|astrophotography|nebulae|galaxies|star fields?|milky way|aurorae?|aurora borealis|aurora australis|moon|lunar|solar eclipses?|planets?)\b/i, gallery: 'Astronomy' },
        { re: /\b(churches?|cathedrals?|basilicas?|mosques?|synagogues?|temples?|monasteries|abbeys|chapels?)\b/i, gallery: 'Places/Architecture/Religious buildings' },
        { re: /\b(castles?|chateaux?|fortresses?|palaces?|fortifications?)\b/i, gallery: 'Places/Architecture/Castles and fortifications' },
        { re: /\b(bridges?|viaducts?|aqueducts?)\b/i, gallery: 'Places/Architecture/Bridges' },
        { re: /\b(skylines?|cityscapes?|urban (?:landscapes?|scenes?))\b/i, gallery: 'Places/Architecture/Cityscapes' },
        { re: /\b(interiors?|inside (?:of |the )?(?:church|cathedral|building|hall|museum)|ceiling|nave|halls?|libraries?)\b/i, gallery: 'Places/Interiors' },
        { re: /\b(buildings?|architecture|towers?|skyscrapers?|stadiums?|villas?|houses?|monuments?|memorials?)\b/i, gallery: 'Places/Architecture/Exteriors' },
        { re: /\b(mountains?|peaks?|valleys?|canyons?|cliffs?|gorges?|fjords?|glaciers?|volcanoes?|caves?|deserts?|dunes?|geological|geology)\b/i, gallery: 'Places/Natural' },
        { re: /\b(forests?|jungles?|woodlands?|meadows?|prairies?|wetlands?|swamps?|tundras?|rainforests?)\b/i, gallery: 'Places/Natural' },
        { re: /\b(beaches?|coast(?:s|al|line)?|seas?|oceans?|lakes?|rivers?|waterfalls?|estuaries|islands?|atolls?)\b/i, gallery: 'Places/Natural' },
        { re: /\b(parks?|gardens?|national parks?|protected areas?|landscape photography|landscapes?|scenery)\b/i, gallery: 'Places/Natural' },
        // ── People / Activities ─────────────────────────────────────────
        { re: /\b(portraits?|headshots?|self[- ]?portraits?|selfies?)\b/i, gallery: 'People/Portrait' },
        { re: /\b(sports?|athletes?|football|soccer|basketball|tennis|cycling|skiing|surfing|swimming|olympic|olympics?|marathon|races?)\b/i, gallery: 'Sports' },
        { re: /\b(dancers?|dancing|ballet|theatre|theater|opera|performers?|performance|musicians?|orchestras?|concerts?|festivals?|carnivals?|parades?)\b/i, gallery: 'People/Work' },
        { re: /\b(weddings?|ceremonies|religious ceremonies|funerals?|rites?|protests?|demonstrations?|crowds?)\b/i, gallery: 'People/Work' },
        { re: /\b(people|persons?|human(?:s|ity)?|crowds?|workers?|professions?|activities)\b/i, gallery: 'People' },
        // ── Objects ──────────────────────────────────────────────────────
        { re: /\b(aircraft|airplanes?|helicopters?|gliders?|jets?|airliners?|warplanes?)\b/i, gallery: 'Objects/Vehicles/Air transport' },
        { re: /\b(cars?|automobiles?|trucks?|buses|motorcycles|vehicles?|locomotives?|trains?|railways?|trams|metros?)\b/i, gallery: 'Objects/Vehicles/Land vehicles' },
        { re: /\b(ships?|boats?|vessels?|sailing|yachts?|submarines?|maritime)\b/i, gallery: 'Objects/Vehicles/Water transport' },
        { re: /\b(food|foods?|cuisine|dishes|meals?|beverages?|fruits?|vegetables?|baking|cooking)\b/i, gallery: 'Food and drink' },
        { re: /\b(weapons?|firearms?|guns?|rifles?|swords?|knives|cannons?|missiles?)\b/i, gallery: 'Objects' },
        { re: /\b(coins?|stamps?|currencies|banknotes?|medals?|jewell?ery|watches?)\b/i, gallery: 'Objects/Monetary items' },
        { re: /\b(tools?|machinery|instruments?|electronics|computers?|cameras?|telescopes?)\b/i, gallery: 'Objects' },
        { re: /\b(toys?|dolls?|games?|board games?|musical instruments?)\b/i, gallery: 'Objects' },
        { re: /\b(textiles?|clothing|costumes?|fashion|jewell?ery|crafts?)\b/i, gallery: 'Objects' },
        // ── Historical / Non-photographic ───────────────────────────────
        { re: /\b(paintings?|portraits|frescoes?|murals?)\b/i, gallery: 'Non-photographic media/Others' },
        { re: /\b(drawings?|sketches|illustrations?|engravings?|lithographs?|etchings?|prints?|woodcuts?)\b/i, gallery: 'Non-photographic media/Printed' },
        { re: /\b(sculptures?|statues?|reliefs?)\b/i, gallery: 'Objects/Sculptures' },
        { re: /\b(maps?|cartography|atlas(?:es)?|charts?)\b/i, gallery: 'Non-photographic media/Maps' },
        { re: /\b(diagrams?|schematics?|charts?|graphs?|flowcharts?)\b/i, gallery: 'Non-photographic media/Science' },
        { re: /\b(coats? of arms|heraldry|flags?|emblems?|logos?)\b/i, gallery: 'Non-photographic media/Others' },
        { re: /\b(historical (?:photos?|images?)|19th[- ]century|18th[- ]century|17th[- ]century|daguerreotypes?|pre[- ]?1900)\b/i, gallery: 'Historical' },
        // ── Microscopy ──────────────────────────────────────────────────
        { re: /\b(microscopy|microscope|micrograph|electron microscopy|sem image|tem image|x[- ]ray|crystallography|histology)\b/i, gallery: 'Non-photographic media/Science' }
    ];

    function suggestGallery( cats, wt ) {
        if ( !cats || !cats.length ) return null;
        var joined = cats.join( ' Β· ' );
        for ( var i = 0; i < FP_GALLERY_RULES.length; i++ ) {
            if ( FP_GALLERY_RULES[ i ].re.test( joined ) ) return FP_GALLERY_RULES[ i ].gallery;
        }
        return null;
    }

    // ─── Shared infrastructure (QIC/VIC + light readiness) ──────────────────

    // Lightweight, self-contained readiness checks for the FP nominate dialog.
    // The full image-quality engine (camera DB, diffraction, sharpness, crop
    // heuristics) lives in fpc-archiver.js and is deliberately NOT duplicated
    // here: those heuristics need ground truth and have caused false-positive
    // friction with nominators. We surface only objective, hard-rule signals
    // from imageinfo. The MediaInfo-caption and depicts checks are appended by
    // the caller (fetchPreSubmitReadiness).
    function runLightChecks( ii ) {
        var checks = [];
        var w = ii.width || 0, h = ii.height || 0;
        var mp = ( w * h ) / 1e6;
        if ( w && h && mp < 2 ) {
            checks.push( {
                name: 'Below FP resolution minimum',
                pass: false,
                note: 'Image is ' + mp.toFixed( 1 ) + ' MP (' + w + 'Γ—' + h + ' px). ' +
                    'Featured pictures normally need at least 2 MP unless there is a special ' +
                    'reason (e.g. a rare historical image, or an SVG).'
            } );
        }
        return checks;
    }

    // Insert a file-page action link, in BOTH #filetoc and the cactions portlet
    // (so it shows regardless of skin/gadget conflicts). Returns the primary
    // clickable <a>, or null if there is nowhere to place it.
    // #filetoc is a plain <ul> in this skin: MediaWiki no longer lays its items
    // out inline, so every gadget that appends to it adds another bullet on
    // another line. Four one-word actions became a four-line list above the
    // photograph. They are a row of actions, so they are laid out as one,
    // and the row covers whatever other gadgets put there too, which is the
    // point: they belong together.
    var filetocStyled = false;

    function styleFileToc() {
        if ( filetocStyled || !document.getElementById( 'filetoc' ) ) {
            return;
        }
        filetocStyled = true;
        mw.util.addCSS(
            '#filetoc{display:flex;flex-wrap:wrap;align-items:center;' +
                'gap:.25em 1.5em;list-style:none;margin-left:0;padding-left:0}' +
            '#filetoc>li{display:inline-block;list-style:none;margin:0}' +
            '#filetoc>li::marker{content:none}'
        );
    }

    function addFileActionLink( id, label, title ) {
        var primary = null;
        var fileToc = document.getElementById( 'filetoc' );
        if ( fileToc ) {
            styleFileToc();
            var li = document.createElement( 'li' );
            li.id = id;
            var a = document.createElement( 'a' );
            a.href = '#';
            a.textContent = label;
            a.title = title;
            li.appendChild( a );
            fileToc.appendChild( li );
            primary = a;
        }
        var portlet = mw.util.addPortletLink( 'p-cactions', '#', label, id + '-portlet', title )
            || mw.util.addPortletLink( 'p-views', '#', label, id + '-portlet', title );
        if ( portlet ) {
            if ( !primary ) {
                primary = portlet;
            } else {
                portlet.addEventListener( 'click', function ( e ) { e.preventDefault(); primary.click(); } );
            }
        }
        return primary;
    }

    // Build a centred modal reusing the FP nominate dialog's chrome. Returns
    // { overlay, dialog, close, body, footer }. Esc / Γ— / Cancel dismiss it.
    function buildModal( titleHtml ) {
        var overlay = document.createElement( 'div' );
        overlay.className = 'fpc-archiver-nominate-overlay';
        var dialog = document.createElement( 'div' );
        dialog.className = 'fpc-archiver-nominate-dialog cn-compact-dialog';
        dialog.innerHTML =
            '<div class="fpc-nominate-header">' +
            '  <div class="fpc-nominate-title">' + titleHtml + '</div>' +
            '  <button type="button" class="fpc-nominate-close" title="Close (Esc)">Γ—</button>' +
            '</div>' +
            '<div class="fpc-nominate-body cn-compact-body"></div>' +
            '<div class="fpc-nominate-footer">' +
            '  <div class="fpc-nominate-status"></div>' +
            '  <div class="fpc-nominate-actions">' +
            '    <button class="fpc-nominate-cancel">Cancel</button>' +
            '    <button class="fpc-nominate-submit"><b>Submit nomination</b></button>' +
            '  </div>' +
            '</div>';
        overlay.appendChild( dialog );
        document.body.appendChild( overlay );
        function close() {
            document.removeEventListener( 'keydown', escHandler );
            overlay.remove();
        }
        var escHandler = function ( e ) { if ( e.key === 'Escape' ) close(); };
        document.addEventListener( 'keydown', escHandler );
        dialog.querySelector( '.fpc-nominate-close' ).addEventListener( 'click', close );
        dialog.querySelector( '.fpc-nominate-cancel' ).addEventListener( 'click', close );
        return {
            overlay: overlay,
            dialog: dialog,
            close: close,
            body: dialog.querySelector( '.cn-compact-body' ),
            status: dialog.querySelector( '.fpc-nominate-status' ),
            submitBtn: dialog.querySelector( '.fpc-nominate-submit' ),
            cancelBtn: dialog.querySelector( '.fpc-nominate-cancel' )
        };
    }

    // Generic single-revision page read. Returns { wt, baserevid, basetimestamp,
    // missing }. REJECTS on a genuine read failure so callers do NOT silently
    // treat a transient API error as "page empty / not nominated".
    function fetchPage( title, api ) {
        return api.get( {
            action: 'query', titles: title,
            prop: 'revisions', rvprop: 'content|timestamp|ids', rvslots: 'main',
            format: 'json', formatversion: 2
        } ).then( function ( res ) {
            var p = ( res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
            if ( p.missing ) return { wt: '', baserevid: 0, basetimestamp: null, missing: true };
            var rev = p.revisions && p.revisions[ 0 ];
            if ( !rev || !rev.slots || !rev.slots.main ) {
                throw new Error( 'Could not read [[' + title + ']].' );
            }
            return {
                wt: rev.slots.main.content || '',
                baserevid: rev.revid,
                basetimestamp: rev.timestamp,
                missing: false
            };
        } );
    }

    // Extract positional parameter 1 of the first \u007b{/Xxx|…|…}} template on a
    // line, honouring \u007b{…}} and [[…]] nesting (so a [[User:X|X]] pipe inside the
    // parameter is NOT mistaken for the parameter separator). Used to read the
    // NOMINATOR signature (param 1) without counting reviewer sigs (param 2).
    function firstTemplateParam1( s ) {
        var start = s.indexOf( '\u007b{/' );
        if ( start < 0 ) return '';
        var brace = 0, bracket = 0, i = start, firstPipe = -1;
        for ( ; i < s.length; i++ ) {
            var two = s.substr( i, 2 );
            if ( two === '\u007b{' ) { brace++; i++; continue; }
            if ( two === '}}' ) { brace--; i++; if ( brace === 0 ) break; continue; }
            if ( two === '[[' ) { bracket++; i++; continue; }
            if ( two === ']]' ) { if ( bracket > 0 ) bracket--; i++; continue; }
            if ( brace === 1 && bracket === 0 && s.charAt( i ) === '|' ) {
                if ( firstPipe < 0 ) { firstPipe = i; }
                else { return s.slice( firstPipe + 1, i ); }
            }
        }
        return firstPipe >= 0 ? s.slice( firstPipe + 1, i ) : '';
    }

    // True if the given wikitext fragment links to [[User:<canon>]] (any
    // talk/user variant). Used to attribute a nomination to its signer.
    function fragmentHasUser( frag, canon ) {
        var re = /\[\[\s*:?\s*User(?:[ _]talk)?\s*:\s*([^\]|]+?)\s*(?:\|[^\]]*)?\]\]/gi;
        var m;
        while ( ( m = re.exec( frag ) ) !== null ) {
            if ( canonUser( m[ 1 ].replace( /[#/].*$/, '' ).trim() ) === canon ) return true;
        }
        return false;
    }

    // Make a user free-text field safe to interpolate into a wiki template
    // parameter. Returns { ok, value, error }. It does NOT blindly entity-encode
    // braces (VIC scopes legitimately contain [[links]] and \u007b{c|…}} templates);
    // instead it REQUIRES the field to be brace/bracket balanced, so it can
    // never close the surrounding template early or leak the rest of the page,
    // and converts only TOP-LEVEL pipes to \u007b{!}} (pipes inside [[…]] / \u007b{…}}
    // are left intact). Unbalanced input is rejected so the caller can ask the
    // user to fix it rather than silently writing corrupt wikitext to a shared
    // page (which would also break the param-1 nominator-sig parsing the QIC
    // daily-cap relies on).
    function sanitizeWikiField( s ) {
        var v = ( s || '' ).replace( /[\r\n]+/g, ' ' ).replace( /\s+/g, ' ' ).trim();
        var brace = 0, bracket = 0, out = '';
        for ( var i = 0; i < v.length; i++ ) {
            var two = v.substr( i, 2 );
            if ( two === '\u007b{' ) { brace++; out += two; i++; continue; }
            if ( two === '}}' ) { brace--; if ( brace < 0 ) return { ok: false, error: 'an unbalanced "}}"' }; out += two; i++; continue; }
            if ( two === '[[' ) { bracket++; out += two; i++; continue; }
            if ( two === ']]' ) { bracket--; if ( bracket < 0 ) return { ok: false, error: 'an unbalanced "]]"' }; out += two; i++; continue; }
            if ( v.charAt( i ) === '|' && brace === 0 && bracket === 0 ) { out += '\u007b{!}}'; continue; }
            out += v.charAt( i );
        }
        if ( brace !== 0 ) return { ok: false, error: 'an unbalanced "\u007b{"' };
        if ( bracket !== 0 ) return { ok: false, error: 'an unbalanced "[["' };
        return { ok: true, value: out };
    }

    // ─── Featured Picture nomination (moved verbatim from fpc-archiver.js) ───
    // ─── 2-active-nomination limit enforcement on subpage creation ───────────
    // Per FPC rule 11: a nominator may not have more than 2 open nominations
    // at once. When the user starts creating a new FPC subpage and already has
    // β‰₯2 active noms not near closure, show a warning and disable the save
    // button (with explicit override).
    function checkActiveNomLimitOnEdit( api ) {
        var pageName = ( mw.config.get( 'wgPageName' ) || '' ).replace( /_/g, ' ' );
        var action = mw.config.get( 'wgAction' );
        var articleId = mw.config.get( 'wgArticleId' );
        var user = mw.config.get( 'wgUserName' );
        if ( !user ) return;
        if ( action !== 'edit' && action !== 'submit' ) return;
        // Only on FPC nomination subpages (File:, Set/, removal/)
        if ( !/^Commons:Featured picture candidates\/(File:|Set\/|removal\/)/.test( pageName ) ) return;
        // Only when creating a NEW page (articleId 0 = page doesn't exist yet)
        if ( articleId !== 0 ) return;
        fetchActiveNomsByUser( user, api ).then( function ( noms ) {
            // Consider only nominations < 7 days old as "not close to closing"
            // (last 2 days of the 9-day window count as near closure).
            var stillFar = noms.filter( function ( n ) { return n.daysOld < 7; } );
            if ( stillFar.length < 2 ) return;
            renderActiveNomLimitBanner( user, stillFar );
        } ).catch( function ( err ) {
            // Do NOT silently swallow a read failure. That would look like
            // "0 active nominations" and let the user save a 3rd concurrent
            // nomination past rule 11. Make the failure visible so they can
            // re-check manually before saving.
            try { console.warn( '[commons-nominator] rule-11 precheck read failed:', err ); } catch ( e ) {}
            mw.notify( 'Could not verify your active FPC nominations (read error). ' +
                'Please re-check rule 11 (max 2 active nominations) before saving.',
                { type: 'warn', autoHide: false } );
        } );
    }

    function fetchActiveNomsByUser( username, api ) {
        return api.get( {
            action: 'query', titles: 'Commons:Featured picture candidates/candidate list',
            prop: 'revisions', rvprop: 'content', rvslots: 'main',
            formatversion: 2
        } ).then( function ( res ) {
            var page = ( res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
            if ( !page.revisions ) return [];
            var wt = page.revisions[ 0 ].slots.main.content || '';
            var transclusions = wt.match( /\{\{(Commons:Featured picture candidates\/[^|}\n]+)\}\}/g ) || [];
            var titles = transclusions.map( function ( m ) {
                return m.slice( 2, -2 ).trim();
            } );
            if ( !titles.length ) return [];
            // Batch-fetch subpages
            var batches = [];
            for ( var i = 0; i < titles.length; i += 40 ) batches.push( titles.slice( i, i + 40 ) );
            return Promise.all( batches.map( function ( b ) {
                return api.get( {
                    action: 'query', titles: b.join( '|' ),
                    prop: 'revisions', rvprop: 'content', rvslots: 'main',
                    formatversion: 2
                } );
            } ) ).then( function ( results ) {
                var matched = [];
                results.forEach( function ( res ) {
                    ( res.query && res.query.pages || [] ).forEach( function ( p ) {
                        if ( p.missing || !p.revisions ) return;
                        var wt = p.revisions[ 0 ].slots.main.content || '';
                        var nominator = detectUploader( wt );
                        if ( !nominator ) return;
                        // Rule 11 counts subpages OWNED by the user (the user who
                        // created them = the nominator), not files they uploaded
                        // or created. The nominator is the user whose signature
                        // sits closest to the first UTC timestamp in the wikitext.
                        if ( canonUser( nominator ) !== canonUser( username ) ) return;
                        // Skip nominations that are effectively done but still
                        // transcluded in candidate_list because FPCBot hasn't
                        // archived them yet (the bot polls every ~30 min, so
                        // there is always a window where closed noms sit here).
                        // Any of the following means the slot is no longer
                        // "spent" against the nominator's rule-11 budget:
                        //   - \u007b{FPC-results-reviewed}}  closer wrote a verdict
                        //   - \u007b{Withdraw}}              nominator pulled out
                        //   - \u007b{FPX|...}}               5th-day-rule fast-fail
                        //   - \u007b{FPD|...}}               disqualified (rule 11 etc.)
                        if ( /\{\{\s*FPC-results-reviewed\b/i.test( wt ) ) return;
                        if ( /\{\{\s*Withdraw\s*[}|]/i.test( wt ) ) return;
                        if ( /\{\{\s*FPX\s*[}|]/i.test( wt ) ) return;
                        if ( /\{\{\s*FPD\s*[}|]/i.test( wt ) ) return;
                        // Detect nomination date, the first UTC timestamp in wikitext.
                        var ts = firstUtcTimestamp( wt );
                        if ( !ts ) return;
                        var daysOld = ( Date.now() - ts.getTime() ) / 86400000;
                        matched.push( { title: p.title, daysOld: daysOld } );
                    } );
                } );
                return matched;
            } );
        } );
        // NOTE: deliberately NOT catching here. A transient read failure must
        // REJECT so the click-time precheck (Promise.all + catch) surfaces a
        // "retry" message instead of silently treating the error as "0 active
        // nominations" and letting the user nominate past the rule-11 cap.
    }

    function firstUtcTimestamp( wt ) {
        var months = { January: 0, February: 1, March: 2, April: 3, May: 4, June: 5,
            July: 6, August: 7, September: 8, October: 9, November: 10, December: 11 };
        var m = wt.match( /(\d{1,2}):(\d{2}),\s+(\d{1,2})\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{4})\s*\(UTC\)/ );
        if ( !m ) return null;
        return new Date( Date.UTC( parseInt( m[ 5 ], 10 ), months[ m[ 4 ] ], parseInt( m[ 3 ], 10 ),
            parseInt( m[ 1 ], 10 ), parseInt( m[ 2 ], 10 ) ) );
    }

    function renderActiveNomLimitBanner( user, activeNoms ) {
        var banner = document.createElement( 'div' );
        banner.className = 'fpc-archiver-2nom-banner';
        var listHtml = activeNoms.map( function ( n ) {
            var rem = ( 9 - n.daysOld ).toFixed( 1 );
            var name = n.title.replace( /^Commons:Featured picture candidates\//, '' );
            return '<li><a href="/wiki/' + encodeURIComponent( n.title ) + '" target="_blank">' +
                escapeAttr( name ) + '</a> with <b>' + rem + ' days remaining</b></li>';
        } ).join( '' );
        banner.innerHTML =
            '<div class="fpc-archiver-2nom-head">⚠ FPC two-active-nomination limit (rule 11)</div>' +
            '<div>You already have <b>' + activeNoms.length + '</b> active nominations not yet near closure:</div>' +
            '<ul>' + listHtml + '</ul>' +
            '<div>Per <a href="https://commons.wikimedia.org/wiki/Commons:Featured_picture_candidates/rules#General_rules" target="_blank">rule 11</a>, ' +
            'please wait until one closes before publishing another. ' +
            'Saving has been disabled. <button type="button" class="fpc-archiver-2nom-override">I understand, let me save anyway</button></div>';

        // Insert at the top of the editor area
        var host = document.getElementById( 'mw-content-text' ) || document.body;
        host.insertBefore( banner, host.firstChild );

        // Disable save buttons (both old-skin and 2010+ wikieditor variants)
        var saveBtns = [];
        [ '#wpSave', 'button[name="wpSave"]', '#wpSaveWidget button', '.ve-ui-toolbar-saveButton button' ]
            .forEach( function ( sel ) {
                document.querySelectorAll( sel ).forEach( function ( b ) { saveBtns.push( b ); } );
            } );
        saveBtns.forEach( function ( b ) {
            b.disabled = true;
            b.dataset.fpcLockedReason = 'fpc-archiver: 2-active-nomination limit';
            b.title = 'Disabled by fpc-archiver. See the banner above.';
        } );

        // Intercept form submit at the capture phase as a hard block
        var form = document.getElementById( 'editform' );
        function blocker( e ) {
            if ( banner._unlocked ) return;
            e.preventDefault();
            e.stopImmediatePropagation();
            window.scrollTo( { top: 0, behavior: 'smooth' } );
            mw.notify( 'Save blocked: you already have 2 active FPC nominations (rule 11). Use the override button if you really want to proceed.', { type: 'error', autoHide: false } );
        }
        if ( form ) form.addEventListener( 'submit', blocker, true );

        banner.querySelector( '.fpc-archiver-2nom-override' ).addEventListener( 'click', function () {
            banner._unlocked = true;
            saveBtns.forEach( function ( b ) {
                b.disabled = false;
                b.title = '';
                delete b.dataset.fpcLockedReason;
            } );
            if ( form ) form.removeEventListener( 'submit', blocker, true );
            banner.style.opacity = '0.6';
            banner.querySelector( '.fpc-archiver-2nom-override' ).disabled = true;
            banner.querySelector( '.fpc-archiver-2nom-override' ).textContent = 'Override active, save now allowed';
        } );
    }

    // ─── Nominate-for-FP button on File: pages ──────────────────────────────
    // Adds a "Nominate for FP" portlet link on File: pages. Click β†’ checks the
    // user's active-nominations count (rule 11) and either blocks with a
    // message or opens a small dialog to compose the nomination, then creates
    // the subpage + appends the transclusion to the candidate list.
    function setupNominateButton( api ) {
        var _ns = mw.config.get( 'wgNamespaceNumber' );
        var _page = mw.config.get( 'wgPageName' ) || '';
        var _user = mw.config.get( 'wgUserName' );
        try { console.log( '[fpc-archiver] setupNominateButton invoked', { ns: _ns, page: _page, user: _user, hasFiletoc: !!document.getElementById( 'filetoc' ) } ); } catch (e) {}
        if ( _ns !== 6 ) return; // File: namespace only
        var pageName = _page;
        if ( !/^File:/.test( pageName ) ) return;
        var user = _user;
        if ( !user ) return;
        // Skip if file is already a featured picture (has FPpromoted template),
        // quick heuristic via category check would require an extra request,
        // so we just allow nomination and let the wiki rules check duplicates.
        // Insert the nominate link in MULTIPLE places so the user always sees
        // it regardless of skin / gadget conflicts:
        //   1. As an <li> inside #filetoc (the "File Β· File history Β· File
        //      usage on Commons Β· Metadata" bar) so it sits next to the QI
        //      nominator gadget link, that's where users actually look on a
        //      file page.
        //   2. As a portlet link in p-cactions (Vector 2022 "More" dropdown,
        //      tab on legacy Vector) so it shows up in the skin chrome too.
        // Whichever one renders first becomes the canonical `link` (the
        // promise chain below updates its label/colour from QI/VI/FP state).
        var link;
        var TITLE_TEXT = '🌟 Nominate for FP';
        var TITLE_DESC = 'Nominate this file for Featured Picture Candidates (rule-11 checked)';
        var fileToc = document.getElementById( 'filetoc' );
        if ( fileToc ) {
            var li = document.createElement( 'li' );
            li.id = 'pt-fpc-nominate';
            var aLink = document.createElement( 'a' );
            aLink.href = '#';
            aLink.textContent = TITLE_TEXT;
            aLink.title = TITLE_DESC;
            li.appendChild( aLink );
            // Append at the END so the QI nominator (which prepends) stays
            // visually first, both coexist without fighting for first slot.
            fileToc.appendChild( li );
            link = aLink;
            try { console.log( '[fpc-archiver] Nominate link added to #filetoc' ); } catch (e) {}
        }
        // Always also add a portlet link (different id) as a backup target,
        // useful when the user's custom CSS hides #filetoc or when something
        // else clears it after our insertion.
        var portletLink = mw.util.addPortletLink(
            'p-cactions', '#', TITLE_TEXT,
            'pt-fpc-nominate-portlet', TITLE_DESC
        ) || mw.util.addPortletLink(
            'p-views', '#', TITLE_TEXT,
            'pt-fpc-nominate-portlet', TITLE_DESC
        );
        if ( portletLink ) {
            try { console.log( '[fpc-archiver] Nominate link added to portlet' ); } catch (e) {}
            if ( !link ) link = portletLink;
            // Mirror clicks from the portlet to the primary handler.
            if ( link !== portletLink ) {
                portletLink.addEventListener( 'click', function ( e ) {
                    e.preventDefault();
                    link.click();
                } );
            }
        }
        if ( !link ) {
            try { console.warn( '[fpc-archiver] Could not insert Nominate link, no #filetoc and no portlet target' ); } catch (e) {}
            return;
        }

        // Both the QI/VI assessment check AND the "is this file currently
        // being nominated?" check fire in parallel. The link is held disabled
        // (pointer-events:none + faded) until BOTH resolve so a fast click
        // never opens a Nominate dialog for a file that already has an
        // active nomination, the dialog would just alert "Cannot nominate"
        // anyway, so the gate avoids the confusing intermediate state.
        link.style.pointerEvents = 'none';
        link.style.opacity = '0.6';
        if ( portletLink ) { portletLink.style.pointerEvents = 'none'; portletLink.style.opacity = '0.6'; }
        var assessmentsPromise = checkFileAssessments( pageName, api );
        var nomCheckPromise = checkFileAlreadyNominated( pageName, api );
        Promise.all( [ assessmentsPromise, nomCheckPromise ] ).then( function ( results ) {
            var a = results[ 0 ];
            var existingNom = results[ 1 ];
            // Re-enable interaction. Specific cases below may strip the click
            // handler entirely (via clone+replace), which is fine.
            link.style.pointerEvents = '';
            link.style.opacity = '';
            if ( portletLink ) { portletLink.style.pointerEvents = ''; portletLink.style.opacity = ''; }
            // Priority 1: file already has an open FPC nomination β†’ morph the
            // link into a "πŸ“‹ Open FPC nomination" pointer at the existing
            // subpage. Clone+replace strips the click handler so the dialog
            // never opens; the link follows the href on normal click.
            if ( existingNom ) {
                var nomUrl = '/wiki/' + encodeURI( existingNom.replace( / /g, '_' ) );
                function morphToOpenLink( a2 ) {
                    if ( !a2 ) return;
                    a2.textContent = 'πŸ“‹ Open FPC nomination';
                    a2.title = 'This file already has an open FPC nomination: ' + existingNom;
                    a2.href = nomUrl;
                    var clone = a2.cloneNode( true );
                    if ( a2.parentNode ) a2.parentNode.replaceChild( clone, a2 );
                }
                morphToOpenLink( link );
                morphToOpenLink( portletLink );
                return;
            }
            // Priority 2: assessment styling (QI / VI / FP).
            var li = link.closest( 'li' ) || link;
            if ( a.isFP ) {
                li.classList.add( 'fpc-nominate-already-fp', 'cn-disabled' );
                link.setAttribute( 'aria-disabled', 'true' );
                link.title = 'Inactive, already a Featured Picture; it can\'t be nominated again.';
            } else if ( a.isQI && a.isVI ) {
                li.classList.add( 'fpc-nominate-qi-vi' );
                link.textContent = '🌟 Nominate for FP (QI + VI βœ“)';
                link.title = 'Already both Quality Image and Valued Image. Strong FP candidate.';
            } else if ( a.isQI ) {
                li.classList.add( 'fpc-nominate-qi' );
                link.textContent = '🌟 Nominate for FP (QI βœ“)';
                link.title = 'Already a Quality Image. Strong FP candidate.';
            } else if ( a.isVI ) {
                li.classList.add( 'fpc-nominate-vi' );
                link.textContent = '🌟 Nominate for FP (VI βœ“)';
                link.title = 'Already a Valued Image';
            }
        } );

        link.addEventListener( 'click', function ( e ) {
            e.preventDefault();
            // Inactive when already a Featured Picture, bail before any
            // "Checking…"/network so the link does nothing (the tooltip explains).
            if ( ( link.closest( 'li' ) || link ).classList.contains( 'cn-disabled' ) ) return;
            link.style.opacity = '0.5';
            var oldText = link.textContent;
            link.textContent = 'Checking…';
            // Three preconditions, all checked in parallel:
            //   (1) File is not already a Featured Picture
            //   (2) File is not currently nominated (subpage transcluded in
            //       candidate list)
            //   (3) User has <2 active nominations near closure (rule 11)
            Promise.all( [
                assessmentsPromise,
                checkFileAlreadyNominated( pageName, api ),
                fetchActiveNomsByUser( user, api )
            ] ).then( function ( results ) {
                link.style.opacity = '';
                link.textContent = oldText;
                var assess = results[ 0 ];
                var existingNom = results[ 1 ];
                var activeNoms = results[ 2 ];
                if ( assess.isFP ) {
                    alert( 'Cannot nominate. This image is already a Featured Picture.\n\n' +
                        'Found in category: "' + assess.fpCategory + '".' );
                    return;
                }
                if ( existingNom ) {
                    var goto = confirm( 'Cannot nominate. This image already has an open FPC nomination:\n\n' +
                        existingNom + '\n\nOpen the existing nomination?' );
                    if ( goto ) {
                        window.location.href = '/wiki/' + encodeURIComponent( existingNom );
                    }
                    return;
                }
                var stillFar = activeNoms.filter( function ( n ) { return n.daysOld < 7; } );
                if ( stillFar.length >= 2 ) {
                    var list = stillFar.map( function ( n ) {
                        return 'β€’ ' + n.title.replace( /^Commons:Featured picture candidates\//, '' ) +
                            ' (' + ( 9 - n.daysOld ).toFixed( 1 ) + ' days remaining)';
                    } ).join( '\n' );
                    alert( 'Cannot nominate. FPC rule 11 caps active nominations.\n\n' +
                        'You already have ' + stillFar.length + ' active FPC nominations:\n\n' +
                        list + '\n\nPlease wait until one closes (or is within 2 days of closing) before nominating another.' );
                    return;
                }
                showNominationDialog( pageName, user, api );
            } ).catch( function ( err ) {
                link.style.opacity = '';
                link.textContent = oldText;
                console.warn( '[fpc-archiver] nominate-precheck failed:', err );
                alert( 'Failed to check nomination preconditions. Please retry in a moment.' );
            } );
        } );
    }

    // Detect Featured Picture / Quality Image / Valued Image assessments,
    // primarily via the templates that the assessment processes add to the
    // file's description page, with category membership as fallback. All three
    // are independent tracks on Commons; a file can hold any combination.
    function checkFileAssessments( filePageName, api ) {
        return api.get( {
            action: 'query', titles: filePageName,
            prop: 'revisions|categories',
            rvprop: 'content', rvslots: 'main',
            cllimit: 'max',
            format: 'json', formatversion: 2
        } ).then( function ( res ) {
            var p = ( res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
            var wt = ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots && p.revisions[ 0 ].slots.main && p.revisions[ 0 ].slots.main.content ) || '';
            var cats = ( p.categories || [] ).map( function ( c ) { return c.title; } );

            // Template-level detection (primary signal, these are placed
            // directly by the assessment process).
            //   FP: \u007b{Featured picture}}, \u007b{FP}}, \u007b{Assessments|...featured=1...}}
            //   QI: \u007b{QualityImage}}, \u007b{Quality image}}
            //   VI: \u007b{Valued image}}, \u007b{VI}}
            var fpTplRe = /\{\{\s*(?:Featured[ _]?picture|FP|Featured[ _]image|Featured[ _]media)\s*[}|]/i;
            var fpAssessRe = /\{\{\s*Assessments\b[^}]*\b(?:com[-_ ]?featured|featured)\s*=\s*(?:1|yes|true)\b/i;
            var qiTplRe = /\{\{\s*(?:QualityImage|Quality[ _]image)\b/i;
            var viTplRe = /\{\{\s*(?:Valued[ _]image|VI)\s*[|}]/i;

            // Category-level detection (fallback, covers cases where the
            // bot updated the category but the descriptive template was
            // already removed or not yet added)
            var fpCatRe = /^Category:Featured (?:pictures? on Wikimedia Commons|pictures? by User:|pictures? of |picture(?: of the (?:day|year))?)/i;
            var qiCatRe = /^Category:Quality images?\b/i;
            var viCatRe = /^Category:Valued image\b/i;

            var isFP = fpTplRe.test( wt ) || fpAssessRe.test( wt );
            var isQI = qiTplRe.test( wt );
            var isVI = viTplRe.test( wt );
            var fpCat = null, qiCat = null, viCat = null;
            for ( var i = 0; i < cats.length; i++ ) {
                if ( !fpCat && fpCatRe.test( cats[ i ] ) ) fpCat = cats[ i ];
                if ( !qiCat && qiCatRe.test( cats[ i ] ) ) qiCat = cats[ i ];
                if ( !viCat && viCatRe.test( cats[ i ] ) ) viCat = cats[ i ];
            }
            if ( !isFP && fpCat ) isFP = true;
            if ( !isQI && qiCat ) isQI = true;
            if ( !isVI && viCat ) isVI = true;

            return {
                isFP: isFP, fpCategory: fpCat,
                isQI: isQI, qiCategory: qiCat,
                isVI: isVI, viCategory: viCat,
                categories: cats
            };
        } ).catch( function () {
            return { isFP: false, isQI: false, isVI: false, categories: [] };
        } );
    }

    // Check if a File: page already has an open FPC nomination, i.e. the
    // candidate-list page transcludes its candidate subpage (including any
    // renomination variants like .../File:X.jpg/2).
    function checkFileAlreadyNominated( filePageName, api ) {
        return api.get( {
            action: 'query', titles: 'Commons:Featured picture candidates/candidate list',
            prop: 'revisions', rvprop: 'content', rvslots: 'main',
            format: 'json', formatversion: 2
        } ).then( function ( res ) {
            var p = ( res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
            if ( !p.revisions ) return null;
            var wt = p.revisions[ 0 ].slots.main.content || '';
            // MediaWiki treats '_' and ' ' as interchangeable in titles, and
            // the candidate list mixes both spellings (e.g. some entries say
            // ``\u007b{Commons:Featured picture candidates/…}}`` while others say
            // ``\u007b{Commons:Featured_picture_candidates/…}}``, with similarly
            // mixed underscores/spaces inside the file name itself). Match
            // either character at every position so the precheck can't miss
            // an existing nomination because of a typographic detail.
            // Also accept ``/<N>`` renomination suffix.
            var escaped = filePageName.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
            var nameClass = escaped.replace( /[ _]/g, '[ _]' );
            var re = new RegExp( '\\{\\{\\s*Commons:Featured[ _]picture[ _]candidates/' + nameClass + '(/\\d+)?\\s*\\}\\}' );
            var m = wt.match( re );
            if ( !m ) return null;
            return 'Commons:Featured picture candidates/' + filePageName.replace( /_/g, ' ' ) + ( m[ 1 ] || '' );
        } ).catch( function () { return null; } );
    }

    function showNominationDialog( filePageName, user, api ) {
        var fileTitle = filePageName.replace( /^File:/, '' ).replace( /_/g, ' ' );
        // Declared at the dialog-host scope (not inside the .then callback
        // below) so the submit click handler, which lives inside renderDialog
        // and fires much later, can close over it. Assigned once the author
        // fetch resolves.
        var authorParsed = null;
        var originalUploader = null;
        // Fetch original uploader, topical categories, author (from the
        // Information template's |Author= line), and the FP gallery icon map
        // (also acts as the catalog of every known leaf gallery name) in
        // parallel. The icon-map is shared cache so we don\'t re-fetch it on
        // every Nominate click.
        Promise.all( [
            fetchOriginalUploader( filePageName, api ),
            fetchTopicalCategories( filePageName, api ),
            fetchAuthorFromInformation( filePageName, api ),
            loadFpgIconMap( api )
        ] ).then( function ( results ) {
            originalUploader = results[ 0 ];
            var cats = results[ 1 ];
            authorParsed = results[ 2 ];
            var iconMap = results[ 3 ];
            var needsNotify = originalUploader && originalUploader !== user;
            // Smart suggestion: walks category tree up to 2 hops looking for
            // any path leaf name that matches a known FP gallery. Falls back
            // to the rule-based suggestGallery() heuristic when no taxonomy
            // match lands.
            return smartSuggestGallery( cats, iconMap, api ).then( function ( smart ) {
                var suggested = smart || suggestGallery( cats, '' );
                // Only pre-fill when the suggested gallery actually exists on
                // Commons, avoids the embarrassment of pre-filling a
                // redlinked path that the user would then have to debug.
                if ( !suggested ) {
                    renderDialog( originalUploader, needsNotify, null );
                    return;
                }
                return new Promise( function ( resolve ) {
                    api.get( {
                        action: 'query',
                        titles: 'Commons:Featured pictures/' + suggested,
                        prop: 'info',
                        format: 'json', formatversion: 2
                    } ).done( function ( r ) {
                        var pp = ( r && r.query && r.query.pages && r.query.pages[ 0 ] ) || {};
                        renderDialog( originalUploader, needsNotify, pp.missing ? null : suggested );
                        resolve();
                    } ).fail( function () {
                        renderDialog( originalUploader, needsNotify, null );
                        resolve();
                    } );
                } );
            } );
        } );

        function renderDialog( originalUploader, needsNotify, suggestedGallery ) {
            var overlay = document.createElement( 'div' );
            overlay.className = 'fpc-archiver-nominate-overlay';
            var dialog = document.createElement( 'div' );
            dialog.className = 'fpc-archiver-nominate-dialog';
            var notifyHtml = needsNotify
                ? '<label class="fpc-nominate-option"><input type="checkbox" class="fpc-nominate-notify" checked> ' +
                    'Notify <b>' + escapeAttr( originalUploader ) + '</b> on their talk page (in English)</label>'
                : '';
            dialog.innerHTML =
                // Header: single compact line, title + close button.
                '<div class="fpc-nominate-header">' +
                '  <div class="fpc-nominate-title">Nominate <i>' + escapeAttr( fileTitle ) + '</i> for Featured Picture</div>' +
                '  <button type="button" class="fpc-nominate-close" title="Close (Esc)">Γ—</button>' +
                '</div>' +

                // Body: two-column layout. Left = form, right = readiness panel.
                '<div class="fpc-nominate-body">' +
                '  <div class="fpc-nominate-col-form">' +

                // Gallery
                '    <section class="fpc-nominate-section">' +
                '      <div class="fpc-nominate-section-title">Gallery</div>' +
                '      <div class="fpc-nominate-gallery-wrap">' +
                '        <input type="text" class="fpc-nominate-gallery" autocomplete="off" spellcheck="false" placeholder="Type to search or pick from the tree below e.g. Plants/Asterales" value="' + ( suggestedGallery ? escapeAttr( suggestedGallery ) : '' ) + '">' +
                '        <div class="fpc-nominate-gallery-suggest" hidden></div>' +
                '        <div class="fpc-nominate-gallery-tree"></div>' +
                '      </div>' +
                ( suggestedGallery
                    ? '      <div class="fpc-nominate-gallery-hint">πŸ“‚ Pre-filled from this file\'s categories, verify it matches your subject.</div>'
                    : '' ) +
                '    </section>' +

                // Pitch
                '    <section class="fpc-nominate-section">' +
                '      <div class="fpc-nominate-section-title">Pitch <span class="fpc-nominate-section-hint">(optional)</span></div>' +
                '      <textarea class="fpc-nominate-comment" placeholder="What makes this image special? Anything reviewers should know."></textarea>' +
                '    </section>' +

                // Options
                '    <section class="fpc-nominate-section">' +
                '      <div class="fpc-nominate-options">' +
                '        <label class="fpc-nominate-option"><input type="checkbox" class="fpc-nominate-selfsupport" checked> ' +
                '          Add my <code>\u007b{Support}}</code> vote automatically</label>' +
                '        ' + notifyHtml +
                '      </div>' +
                '    </section>' +

                '  </div>' + // /fpc-nominate-col-form

                // Right column: gallery samples (top) + readiness panel (bottom).
                '  <aside class="fpc-nominate-col-readiness">' +
                '    <div class="fpc-nominate-samples-section">' +
                '      <div class="fpc-nominate-section-title">Samples of this category</div>' +
                '      <div class="fpc-nominate-samples-body">Pick a gallery on the left to see samples.</div>' +
                '    </div>' +
                '    <div class="fpc-nominate-readiness-section">' +
                '      <div class="fpc-nominate-section-title">Readiness check</div>' +
                '      <div class="fpc-nominate-readiness-body">πŸ” Running checks…</div>' +
                '    </div>' +
                '  </aside>' +

                '</div>' + // /fpc-nominate-body

                // Footer: sticky action bar.
                '<div class="fpc-nominate-footer">' +
                '  <div class="fpc-nominate-status"></div>' +
                '  <div class="fpc-nominate-actions">' +
                '    <button class="fpc-nominate-cancel">Cancel</button>' +
                '    <button class="fpc-nominate-submit"><b>Submit nomination</b></button>' +
                '  </div>' +
                '</div>';
            overlay.appendChild( dialog );
            document.body.appendChild( overlay );

            var galleryInput = dialog.querySelector( '.fpc-nominate-gallery' );
            var commentInput = dialog.querySelector( '.fpc-nominate-comment' );
            var supportCb = dialog.querySelector( '.fpc-nominate-selfsupport' );
            var notifyCb = dialog.querySelector( '.fpc-nominate-notify' );
            var statusEl = dialog.querySelector( '.fpc-nominate-status' );
            var submitBtn = dialog.querySelector( '.fpc-nominate-submit' );
            var cancelBtn = dialog.querySelector( '.fpc-nominate-cancel' );

            cancelBtn.addEventListener( 'click', function () { overlay.remove(); } );
            var closeBtn = dialog.querySelector( '.fpc-nominate-close' );
            if ( closeBtn ) closeBtn.addEventListener( 'click', function () { overlay.remove(); } );
            // Click-outside-to-close removed: too easy to wipe the form by
            // accident. Use Γ—, Cancel, or Esc to dismiss.
            var escHandler = function ( e ) { if ( e.key === 'Escape' ) overlay.remove(); };
            document.addEventListener( 'keydown', escHandler );
            // Cleanup the listener when the overlay is removed so it doesn't
            // pile up across nominate sessions.
            new MutationObserver( function ( muts, obs ) {
                if ( !document.body.contains( overlay ) ) {
                    document.removeEventListener( 'keydown', escHandler );
                    obs.disconnect();
                }
            } ).observe( document.body, { childList: true, subtree: false } );

            // Sample-thumbnails state. Declared BEFORE setupGalleryTreeBrowser
            // because the tree\'s onNavigate callback fires synchronously
            // during setup, and would read samplesBody as undefined if the
            // ref were assigned after the call.
            var samplesBody = dialog.querySelector( '.fpc-nominate-samples-body' );
            var samplesTimer = null;
            var samplesActivePath = null; // most-recent requested path, to ignore stale responses
            function showSamplesFor( rawPath ) {
                var parts = ( rawPath || '' ).split( '#' );
                var path = parts[ 0 ].trim();
                var section = parts[ 1 ] ? parts[ 1 ].trim() : null;
                // samplesActivePath is keyed by the FULL identifier (path +
                // optional section) so picking a section on the same page
                // doesn\'t race against the whole-page fetch.
                var key = path + ( section ? '#' + section : '' );
                samplesActivePath = key;
                if ( !samplesBody ) return; // defensive: dialog DOM not ready
                if ( !path ) {
                    samplesBody.textContent = 'Pick a gallery on the left to see samples.';
                    return;
                }
                samplesBody.textContent = 'πŸ” Loading samples…';
                fetchGallerySamples( path, section, api ).then( function ( samples ) {
                    if ( samplesActivePath !== key ) return; // user moved on
                    renderGallerySamples( samples, samplesBody, path, section );
                } ).catch( function () {
                    if ( samplesActivePath !== key ) return;
                    samplesBody.textContent = 'Could not load samples for this gallery.';
                } );
            }
            function refreshSamplesFromInput() { showSamplesFor( galleryInput.value ); }
            galleryInput.addEventListener( 'change', refreshSamplesFromInput );
            galleryInput.addEventListener( 'input', function () {
                clearTimeout( samplesTimer );
                samplesTimer = setTimeout( refreshSamplesFromInput, 500 );
            } );

            // Hook up the gallery autocomplete (live prefix-search against
            // existing Commons:Featured pictures/* subpages so the user can't
            // submit a redlink). Bound only after dialog mounts because we
            // need both the input and its suggest container in the DOM.
            setupGalleryAutocomplete( galleryInput, dialog.querySelector( '.fpc-nominate-gallery-suggest' ), api );
            // In-dialog gallery navigator. Always visible, autocomplete and
            // tree-pick are complementary, no toggle. Starts at the parent of
            // the pre-filled suggestion (or root) so siblings are visible.
            setupGalleryTreeBrowser(
                dialog.querySelector( '.fpc-nominate-gallery-tree' ),
                dialog.querySelector( '.fpc-nominate-gallery-suggest' ),
                galleryInput,
                api,
                function ( navigatedPath ) {
                    // Whenever the user drills into a tree node (without
                    // committing it to the input yet), refresh the samples
                    // for that level so the right column shows what kind of
                    // images live there. Empty path = root β†’ show "pick one".
                    showSamplesFor( navigatedPath );
                }
            );
            refreshSamplesFromInput();

            // Kick off the readiness check asynchronously so the dialog
            // renders immediately. Submit stays enabled regardless of the
            // result, the panel is informational, not a blocker.
            var readinessBody = dialog.querySelector( '.fpc-nominate-readiness-body' );
            fetchPreSubmitReadiness( filePageName, api ).then( function ( data ) {
                renderPreSubmitReadiness( data, readinessBody );
            } ).catch( function ( err ) {
                readinessBody.innerHTML = '<span style="color:#9ca3af">Could not run readiness check (' +
                    escapeAttr( ( err && err.message ) || 'unknown error' ) + '). Submitting is still safe.</span>';
            } );

            submitBtn.addEventListener( 'click', function () {
                var gallery = galleryInput.value.trim().replace( /_/g, ' ' ).replace( /\s+/g, ' ' );
                var comment = commentInput.value.trim();
                var selfSupport = supportCb.checked;
                var notifyUploader = notifyCb && notifyCb.checked && needsNotify;
                submitBtn.disabled = true;
                cancelBtn.disabled = true;
                statusEl.textContent = 'Creating nomination subpage…';
                var attribution = buildAttributionLine( authorParsed, originalUploader, user );
                submitNomination( filePageName, fileTitle, user, gallery, comment, selfSupport, attribution, api )
                    .then( function ( subpageTitle ) {
                        statusEl.textContent = 'Adding to candidate list…';
                        return addTransclusionToCandidateList( subpageTitle, api )
                            .then( function () { return subpageTitle; } );
                    } )
                    .then( function ( subpageTitle ) {
                        if ( notifyUploader ) {
                            statusEl.textContent = 'Notifying original uploader…';
                            return postTalkNotification( originalUploader, filePageName, fileTitle, subpageTitle, api )
                                .then( function () { return subpageTitle; } )
                                // Don't fail the nomination if the talk-page
                                // notification post fails (e.g., user has
                                // protected talk page).
                                .catch( function () { return subpageTitle; } );
                        }
                        return subpageTitle;
                    } )
                    .then( function ( subpageTitle ) {
                        statusEl.textContent = 'Done! Opening your nomination…';
                        setTimeout( function () {
                            window.location.href = '/wiki/' + encodeURIComponent( subpageTitle );
                        }, 1000 );
                    } )
                    .catch( function ( err ) {
                        var msg = err && err.error ? ( err.error.code + ': ' + err.error.info ) : ( err && err.message ? err.message : String( err ) );
                        statusEl.innerHTML = '<span style="color:#b91c1c"><b>Error:</b> ' + escapeAttr( msg ) + '</span>';
                        submitBtn.disabled = false;
                        cancelBtn.disabled = false;
                    } );
            } );
        }
    }

    // Returns the username of the user who first uploaded the file (the oldest
    // imageinfo revision). Useful for the courtesy talk-page notification when
    // the nominator differs from the original uploader.
    function fetchOriginalUploader( filePageName, api ) {
        return api.get( {
            action: 'query', titles: filePageName,
            prop: 'imageinfo',
            iiprop: 'user|timestamp',
            iilimit: 'max',
            format: 'json', formatversion: 2
        } ).then( function ( res ) {
            var p = ( res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
            var revs = p.imageinfo || [];
            if ( !revs.length ) return null;
            return revs[ revs.length - 1 ].user || null;
        } ).catch( function () { return null; } );
    }

    // Topical categories of the file (excluding hidden meta cats). Used by
    // the nomination dialog to pre-suggest a gallery via suggestGallery().
    function fetchTopicalCategories( filePageName, api ) {
        return api.get( {
            action: 'query', titles: filePageName,
            prop: 'categories', cllimit: 'max', clshow: '!hidden',
            format: 'json', formatversion: 2
        } ).then( function ( res ) {
            var p = ( res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
            return ( p.categories || [] ).map( function ( c ) { return c.title.replace( /^Category:/, '' ); } );
        } ).catch( function () { return []; } );
    }

    // Extract the |Author= line from the file's Information / Artwork / Art
    // Photo template, plus any username embedded in it (the typical patterns
    // are [[User:X|…]], \u007b{u|X}}, \u007b{User:X}}, or a Special:Contributions link).
    // Falls back to the raw text when no username can be identified, callers
    // that want to dedupe against the uploader/nominator should only treat
    // the username as identity-comparable.
    function fetchAuthorFromInformation( filePageName, api ) {
        return api.get( {
            action: 'query', titles: filePageName,
            prop: 'revisions', rvprop: 'content', rvslots: 'main',
            format: 'json', formatversion: 2
        } ).then( function ( res ) {
            var p = ( res && res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
            var wt = ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots && p.revisions[ 0 ].slots.main && p.revisions[ 0 ].slots.main.content ) || '';
            return parseAuthorField( wt );
        } ).catch( function () { return { raw: null, username: null }; } );
    }

    function parseAuthorField( wt ) {
        // Match |Author= or |author= or |artist= or |photographer=. The value
        // can span the rest of the line; stop at the next top-level pipe (i.e.
        // the next "|" not nested inside \u007b{…}}). Brace-counting is simpler than
        // a non-greedy regex and avoids accidentally grabbing later fields.
        var labelRe = /\|\s*(?:[Aa]uthor|[Aa]rtist|[Pp]hotographer)\s*=\s*/g;
        var labelMatch = labelRe.exec( wt );
        if ( !labelMatch ) return { raw: null, username: null };
        var start = labelMatch.index + labelMatch[ 0 ].length;
        var depth = 0;
        var end = wt.length;
        for ( var i = start; i < wt.length; i++ ) {
            var ch = wt[ i ];
            if ( ch === '{' && wt[ i + 1 ] === '{' ) { depth++; i++; continue; }
            // A closer while depth is already 0 is the END of the enclosing
            // template, not a nested one. Letting depth go negative made the
            // value run on until some later "\u007b{" brought it back to 0, so an
            // Author field that is the last parameter swallowed the rest of the
            // page into the nomination's attribution sentence.
            if ( ch === '}' && wt[ i + 1 ] === '}' ) { if ( depth === 0 ) { end = i; break; } depth--; i++; continue; }
            if ( ch === '[' && wt[ i + 1 ] === '[' ) { depth++; i++; continue; }
            if ( ch === ']' && wt[ i + 1 ] === ']' ) { if ( depth === 0 ) { end = i; break; } depth--; i++; continue; }
            if ( depth === 0 && ( ch === '|' || ch === '\n' ) ) {
                end = i;
                // Allow the value to continue across a newline if the next
                // non-whitespace char is not a new pipe (covers multi-line
                // author fields). Single-line is fine, so break.
                if ( ch === '\n' ) {
                    var rest = wt.slice( i + 1 ).match( /^\s*/ );
                    var afterWs = i + 1 + ( rest ? rest[ 0 ].length : 0 );
                    if ( wt[ afterWs ] === '|' || wt[ afterWs ] === '}' ) break;
                    continue;
                }
                break;
            }
        }
        var raw = wt.slice( start, end ).trim();
        if ( !raw ) return { raw: null, username: null };
        // Search for an embedded username.
        var u = raw.match( /\[\[\s*User\s*:\s*([^|\]\n]+?)\s*[\|\]]/i ) ||
            raw.match( /\{\{\s*(?:u|user|User|U)\s*\|\s*([^|}]+?)\s*[\|}]/i ) ||
            raw.match( /\{\{\s*User\s*:\s*([^|}\s]+?)\s*[\|}]/i ) ||
            raw.match( /Special:Contributions\s*\/\s*([^|\]\s]+)/i );
        var username = u ? u[ 1 ].trim() : null;
        // \u007b{Creator:<full name>}} templates resolve to a wiki user via the
        // creator\'s Linkback / Wikidata "alternative name" mapping. We can\'t
        // resolve them generically from the client without an extra API call,
        // but we DO recognize the script-owner\'s own Creator template by
        // name so files Wilfredor uploaded with the canonical
        // \u007b{Creator:Wilfredo Rafael Rodriguez Hernandez}} dedupe to himself
        // for the attribution sentence. Other users adding their own Creator
        // template should add their alias here.
        if ( !username ) {
            var creator = raw.match( /\{\{\s*Creator\s*:\s*([^|}]+?)\s*[\|}]/i );
            if ( creator ) {
                var name = creator[ 1 ].trim();
                if ( /^Wilfredo\s+Rafael\s+Rodriguez\s+Hernandez$/i.test( name ) ) {
                    username = 'Wilfredor';
                }
            }
        }
        return { raw: raw, username: username };
    }

    // Build the auto-attribution sentence appended to \u007b{Info}}.
    // Rules (with dedupe across author A, uploader U, nominator N):
    //   A === U === N            β†’ "My own work."
    //   A === N (and U == A)     β†’ "My own work."
    //   A === N (U different)    β†’ "I am the photographer; uploaded by <U>."
    //   A === U (A != N)         β†’ "Created and uploaded by <A>."
    //   U === N (A != U)         β†’ "Created by <A>."   (no need to say I uploaded)
    //   All three different      β†’ "Created by <A>, uploaded by <U>."
    //   No A info, U == N        β†’ ""                  (nominator uploaded own; nothing to say)
    //   No A info, U != N        β†’ "Uploaded by <U>."
    function buildAttributionLine( authorParsed, uploader, nominator ) {
        if ( !authorParsed ) authorParsed = { raw: null, username: null };
        var A = authorParsed.username;
        var Araw = authorParsed.raw;
        var U = uploader || null;
        var N = nominator || null;
        function userLink( u ) { return '[[User:' + u + '|' + u + ']]'; }
        // Pick a display token for the author. If no username, use raw text
        // (escape any wikilinks the field already contains is unnecessary,
        // the value comes verbatim from the file's Information template).
        var aLink = A ? userLink( A ) : ( Araw || null );
        var uLink = U ? userLink( U ) : null;
        if ( A && A === U && A === N ) return 'My own work.';
        if ( A && A === N ) {
            if ( U && U === A ) return 'My own work.';
            if ( U && U !== A ) return 'I am the photographer; uploaded by ' + uLink + '.';
            return 'I am the photographer.';
        }
        if ( A && A === U ) {
            return 'Created and uploaded by ' + aLink + '.';
        }
        if ( aLink && U && U === N ) {
            return 'Created by ' + aLink + '.';
        }
        if ( aLink && uLink ) {
            return 'Created by ' + aLink + ', uploaded by ' + uLink + '.';
        }
        if ( aLink && !uLink ) {
            return 'Created by ' + aLink + '.';
        }
        if ( !aLink && uLink && U !== N ) {
            return 'Uploaded by ' + uLink + '.';
        }
        return '';
    }

    // ─── QIC own-work eligibility ────────────────────────────────────────────
    // Quality Image Candidates require the image to be the own work of a
    // Wikimedian, and this tool limits QIC nominations to the CURRENT USER's
    // own work (the QIC link is disabled on any file that isn't yours). "Own
    // work" is recognised from the file page when EITHER signal holds:
    //   1. the |Author=/|artist=/|photographer= field resolves to the current
    //      user, a [[User:X]] / \u007b{u|X}} / \u007b{User:X}} / Special:Contributions
    //      link, or the owner's own \u007b{Creator:…}} template (see parseAuthorField).
    //   2. the description carries an own-work marker (\u007b{own}}, \u007b{own work}} or
    //      \u007b{self}}) AND the file's ORIGINAL uploader is the current user, an
    //      own-work template asserts "uploader == author", so it only proves the
    //      current user's authorship when the uploader is the current user.
    // Everything else (another Wikimedian's own work, a third party's
    // \u007b{Creator:…}}, a plain real-name credit, a public-domain / agency source)
    // is treated as NOT the user's own work.
    var OWN_WORK_RE = /\{\{\s*(?:own(?:[ _]work)?|self)\s*[|}]/i;

    function computeOwnWork( wt, uploader, user ) {
        wt = wt || '';
        var author = parseAuthorField( wt );
        var uNorm = canonUser( user || '' );
        var isOwn = false;
        // (1) Author field resolves to the current user.
        if ( uNorm && author.username && canonUser( author.username ) === uNorm ) {
            isOwn = true;
        }
        // (2) Own-work marker + the file's uploader is the current user.
        if ( !isOwn && uNorm && uploader && canonUser( uploader ) === uNorm && OWN_WORK_RE.test( wt ) ) {
            isOwn = true;
        }
        return { isOwn: isOwn, author: author, uploader: uploader || null };
    }

    // One API round-trip: the file wikitext (author + own-work marker) plus the
    // original uploader (imageinfo, oldest revision) β†’ the computeOwnWork()
    // verdict. On a read error it resolves { isOwn:true, unknown:true } so the
    // link is NOT falsely disabled at load, the click-time re-check gates the
    // actual nomination, and a persistent network failure there surfaces the
    // generic precondition error instead of a wrong "not your own work" claim.
    function checkFileOwnWork( filePageName, user, api ) {
        return api.get( {
            action: 'query', titles: filePageName,
            prop: 'revisions|imageinfo',
            rvprop: 'content', rvslots: 'main',
            iiprop: 'user', iilimit: 'max',
            format: 'json', formatversion: 2
        } ).then( function ( res ) {
            var p = ( res && res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
            var wt = ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots && p.revisions[ 0 ].slots.main && p.revisions[ 0 ].slots.main.content ) || '';
            var revs = p.imageinfo || [];
            var uploader = revs.length ? ( revs[ revs.length - 1 ].user || null ) : null;
            return computeOwnWork( wt, uploader, user );
        } ).catch( function () {
            return { isOwn: true, unknown: true, author: { raw: null, username: null }, uploader: null };
        } );
    }

    // Reason clause appended to the "limited to your own work" message. Name the
    // credited author, else the uploader, when known.
    function ownWorkReason( ow ) {
        if ( ow && ow.author && ow.author.username ) return ' (this file credits ' + ow.author.username + ').';
        if ( ow && ow.uploader ) return ' (uploaded by ' + ow.uploader + ').';
        return '.';
    }

    // Single-file fetch + runChecks for the pre-submit readiness panel shown
    // inside the Nominate dialog. Mirrors what runReadinessChecks does for the
    // batch FPC-page case, condensed to one title. Includes MediaInfo caption /
    // depicts checks because those are top-five blockers reviewers flag.
    function fetchPreSubmitReadiness( filePageName, api ) {
        return api.get( {
            action: 'query', titles: filePageName,
            prop: 'imageinfo|revisions|categories|globalusage|duplicatefiles',
            iiprop: 'size|mime|url|commonmetadata|user|timestamp|sha1',
            iilimit: 'max',
            rvprop: 'content', rvslots: 'main',
            cllimit: 'max', clshow: '!hidden',
            gulimit: 50, guprop: 'url',
            dflimit: 5,
            format: 'json', formatversion: 2
        } ).then( function ( res ) {
            var p = ( res && res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
            var allII = p.imageinfo || [];
            var ii = allII[ 0 ] || {};
            var firstUp = allII[ allII.length - 1 ];
            if ( firstUp ) {
                ii.__firstUploadTimestamp = firstUp.timestamp;
                ii.__firstUploadUser = firstUp.user;
            }
            ii.__pageid = p.pageid;
            ii.__duplicateFiles = ( p.duplicatefiles || [] ).map( function ( d ) { return d.name; } );
            var wt = ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots && p.revisions[ 0 ].slots.main && p.revisions[ 0 ].slots.main.content ) || '';
            var cats = ( p.categories || [] ).map( function ( c ) { return c.title.replace( /^Category:/, '' ); } );
            ii.__isFP = /\{\{\s*(?:Featured[ _]?(?:picture|image|media)|FP)\s*[}|]/i.test( wt ) ||
                cats.some( function ( c ) { return /^Featured pictures?\b/i.test( c ); } );
            ii.__isQI = /\{\{\s*(?:QualityImage|Quality[ _]image)\b/i.test( wt ) ||
                cats.some( function ( c ) { return /^Quality images?\b/i.test( c ); } );
            ii.__isVI = /\{\{\s*(?:Valued[ _]image|VI)\s*[|}]/i.test( wt ) ||
                cats.some( function ( c ) { return /^Valued image\b/i.test( c ); } );
            var gu = p.globalusage || [];
            ii.__globalUsage = { articleCount: gu.length, truncated: gu.length >= 50 };
            // Fetch MediaInfo statements BEFORE running checks so runChecks
            // can consult them (P170 author / P571 date / P7482 source / etc.)
            // and skip the corresponding wikitext-only checks when the data
            // lives on the M-entity.
            var miPromise = ii.__pageid ? api.get( {
                action: 'wbgetentities',
                ids: 'M' + ii.__pageid,
                // M-entities expose statements (not claims). Request 'claims'
                // because the API does not recognise 'statements' as a prop
                // name, the server then populates the response under either
                // statements or claims and we check both keys.
                props: 'labels|claims',
                format: 'json', formatversion: 2
            } ).then( function ( r2 ) {
                var ent = r2 && r2.entities && r2.entities[ 'M' + ii.__pageid ];
                return {
                    labels: ( ent && ent.labels ) || {},
                    statements: ( ent && ( ent.statements || ent.claims ) ) || {}
                };
            } ).catch( function () { return { labels: {}, statements: {} }; } ) :
                Promise.resolve( { labels: {}, statements: {} } );
            return miPromise.then( function ( mi ) {
                ii.__mediaInfoStatements = mi.statements;
                ii.__mediaInfoLabels = mi.labels;
                // Lightweight hard-rule checks only, the full quality engine
                // (runChecks) stays in fpc-archiver.js and is not duplicated here.
                var checks = runLightChecks( ii );
                // MediaInfo caption: accept either a non-empty Wikibase label
                // or a non-empty Information template description (most
                // uploaders write descriptions in |description=\u007b{en|…}}).
                var hasLabel = Object.keys( mi.labels ).some( function ( k ) {
                    return mi.labels[ k ] && mi.labels[ k ].value && mi.labels[ k ].value.trim();
                } );
                var hasInfoDescription = /\|\s*[Dd]escription\s*=\s*\{?\{?\s*[a-z]{2,3}\s*\|\s*[^\s}|][^\n}]*/i.test( wt ) ||
                    /\|\s*[Dd]escription\s*=\s*[^\n|{][^\n|]*/.test( wt );
                if ( !hasLabel && !hasInfoDescription ) {
                    checks.push( { name: 'MediaInfo caption empty', pass: false,
                        note: 'No caption is set in MediaInfo for any language and the Information template has no description either. Add a short caption on the file page before submitting, reviewers expect at least a one-sentence description.' } );
                }
                // Depicts: accept P180 OR a template-based equivalent
                // (\u007b{Depicts}}, \u007b{Wikidata}}, \u007b{Object location}}, etc.).
                var hasP180 = mi.statements.P180 && mi.statements.P180.length;
                var hasTplDepicts = /\{\{\s*(?:Depicts|Wikidata|Object[ _]location|based on|main subject)\b/i.test( wt );
                if ( !hasP180 && !hasTplDepicts ) {
                    checks.push( { name: 'No depicts (P180) statement', pass: false,
                        note: 'MediaInfo has no "depicts" (P180) statement and the file description has no \u007b{Depicts}} / \u007b{Wikidata}} template either. Add at least one depicts entry on the file page before submitting.' } );
                }
                return { checks: checks, imageinfo: ii, wt: wt, cats: cats };
            } );
        } );
    }

    // Render the verdict pill + 3-5 most-relevant signals into the dialog's
    // readiness body. Severity is a copy of renderReadinessBadge's heuristic,
    // critical issues bubble to the top, the rest collapse into a "+ N more".
    function renderPreSubmitReadiness( data, container ) {
        var checks = data.checks || [];
        var failed = checks.filter( function ( c ) { return !c.pass; } );
        container.innerHTML = '';
        var pill = document.createElement( 'div' );
        pill.className = 'fpc-nominate-readiness-pill';
        if ( failed.length === 0 ) {
            pill.classList.add( 'fpc-ready-ok' );
            pill.textContent = 'βœ“ FP-ready (no metadata-level issues)';
            container.appendChild( pill );
            return;
        }
        var criticalRe = /Heavy crop|Severely (?:under|over)exposed|Hard (?:highlight|shadow) clipping|Possible AI-generated|Single colour channel clipped|Tilted (?:horizon|verticals)|Chromatic aberration|Visible noise|Native camera resolution|JPEG blocking|Lens distortion|Heavy vignetting|Banding|Date predates camera|Stitching seam|Over-sharpening|Over-cooked HDR|Information \/ Artwork|missing|Below 60%|Exported below|blown|crushed|MediaInfo|depicts/i;
        var score = failed.reduce( function ( s, c ) { return s + ( criticalRe.test( c.name ) ? 2 : 1 ); }, 0 );
        if ( failed.length <= 2 && score <= 3 ) {
            pill.classList.add( 'fpc-ready-borderline' );
            pill.textContent = '⚠ Borderline (' + failed.length + ' issue' + ( failed.length > 1 ? 's' : '' ) + ')';
        } else {
            pill.classList.add( 'fpc-ready-risky' );
            pill.textContent = '⚠ Risky (' + failed.length + ' issues, severity ' + Math.min( 5, Math.ceil( score / 2 ) ) + '/5)';
        }
        container.appendChild( pill );
        // Critical first, then the rest. Cap at 5 lines, overflow into "more".
        failed.sort( function ( a, b ) {
            return ( criticalRe.test( b.name ) ? 1 : 0 ) - ( criticalRe.test( a.name ) ? 1 : 0 );
        } );
        var lines = document.createElement( 'div' );
        lines.className = 'fpc-nominate-readiness-lines';
        // Right column has room, show every issue with the full explanation
        // visible inline (no truncation, no hover-to-reveal). Severity badge in
        // front of the name makes the priority scannable.
        var maxShown = 8;
        failed.slice( 0, maxShown ).forEach( function ( c ) {
            var item = document.createElement( 'div' );
            item.className = 'fpc-nominate-readiness-line';
            var crit = criticalRe.test( c.name );
            if ( crit ) item.classList.add( 'fpc-nominate-readiness-line-critical' );
            var badge = crit ? '<span class="fpc-nominate-readiness-badge fpc-readiness-badge-crit">!</span>' :
                '<span class="fpc-nominate-readiness-badge fpc-readiness-badge-warn">β€’</span>';
            item.innerHTML = badge + '<div class="fpc-nominate-readiness-line-body">' +
                '<div class="fpc-nominate-readiness-line-name">' + escapeAttr( c.name ) + '</div>' +
                '<div class="fpc-nominate-readiness-line-note">' + escapeAttr( c.note || '' ) + '</div>' +
                '</div>';
            lines.appendChild( item );
        } );
        container.appendChild( lines );
        if ( failed.length > maxShown ) {
            var more = document.createElement( 'div' );
            more.className = 'fpc-nominate-readiness-more';
            more.textContent = '… and ' + ( failed.length - maxShown ) + ' more (full list runs on the FPC page after submit).';
            container.appendChild( more );
        }
    }

    // Live prefix-search against Commons:Featured pictures/* subpages so the
    // Gallery field autocompletes to a real, existing target instead of a
    // redlinked guess. Strips the "Commons:Featured pictures/" prefix when
    // inserting the result (the script always adds it back at submit time).
    function setupGalleryAutocomplete( input, dropdown, api ) {
        var debounceTimer = null;
        var lastQuery = '';
        var activeIdx = -1;
        var currentResults = [];

        function GALLERY_PREFIX() { return 'Commons:Featured pictures/'; }

        function close() {
            dropdown.hidden = true;
            dropdown.innerHTML = '';
            activeIdx = -1;
            currentResults = [];
        }

        function render( results, query ) {
            currentResults = results;
            dropdown.innerHTML = '';
            if ( !results.length ) {
                dropdown.hidden = true;
                return;
            }
            results.forEach( function ( r, i ) {
                var item = document.createElement( 'div' );
                item.className = 'fpc-nominate-gallery-suggest-item';
                if ( i === activeIdx ) item.classList.add( 'active' );
                // Highlight the user's typed substring inside the suggestion.
                var label = r;
                if ( query ) {
                    var qLower = query.toLowerCase();
                    var rLower = label.toLowerCase();
                    var idx = rLower.indexOf( qLower );
                    if ( idx >= 0 ) {
                        label = escapeAttr( r.slice( 0, idx ) ) +
                            '<mark>' + escapeAttr( r.slice( idx, idx + query.length ) ) + '</mark>' +
                            escapeAttr( r.slice( idx + query.length ) );
                    } else {
                        label = escapeAttr( r );
                    }
                } else {
                    label = escapeAttr( r );
                }
                item.innerHTML = label;
                item.addEventListener( 'mousedown', function ( e ) {
                    // mousedown not click, fires before blur so the dropdown
                    // doesn't close before our handler runs.
                    e.preventDefault();
                    input.value = r;
                    close();
                    input.focus();
                    input.dispatchEvent( new Event( 'change' ) );
                } );
                dropdown.appendChild( item );
            } );
            dropdown.hidden = false;
        }

        function search( raw ) {
            // Strip section anchor for the search, galleries are pages, anchors
            // are headings inside them. The user types "Plants/Asterales#Subfamily"
            // but we search "Plants/Asterales".
            var q = raw.split( '#' )[ 0 ].trim();
            if ( !q ) { close(); return; }
            if ( q === lastQuery ) return;
            lastQuery = q;
            api.get( {
                action: 'query',
                list: 'prefixsearch',
                pssearch: GALLERY_PREFIX() + q,
                psnamespace: 4,
                pslimit: 10,
                format: 'json', formatversion: 2
            } ).done( function ( res ) {
                if ( q !== lastQuery ) return; // a newer query has been issued
                var matches = ( res && res.query && res.query.prefixsearch ) || [];
                // Reject the same non-gallery patterns the tree filters out
                // (file-shaped subpages, namespace-prefixed pages, media leaf
                // names) so the autocomplete never offers a path that would
                // produce a broken \u007b{Gallery:}} link.
                var NON_GALLERY = /(?:^|\/)(?:File|Image|Talk|Special|Category|User|Media|MediaWiki):/i;
                var MEDIA_LEAF = /\.(?:jpg|jpeg|png|gif|svg|tif|tiff|webp|ogv|webm|pdf|djvu)$/i;
                var paths = matches
                    .map( function ( m ) { return ( m.title || '' ).replace( /^Commons:Featured pictures\//, '' ); } )
                    .filter( function ( p ) {
                        if ( !p ) return false;
                        if ( NON_GALLERY.test( p ) ) return false;
                        if ( MEDIA_LEAF.test( p.split( '/' ).pop() || '' ) ) return false;
                        return true;
                    } );
                render( paths, q );
            } ).fail( function () { /* silent, autocomplete is best-effort */ } );
        }

        input.addEventListener( 'input', function () {
            clearTimeout( debounceTimer );
            debounceTimer = setTimeout( function () { search( input.value ); }, 200 );
        } );
        input.addEventListener( 'focus', function () {
            // Re-open on focus if the user already has text typed.
            if ( input.value.trim() ) search( input.value );
        } );
        input.addEventListener( 'blur', function () {
            // Small delay so a click on a suggestion still registers.
            setTimeout( close, 150 );
        } );
        input.addEventListener( 'keydown', function ( e ) {
            if ( dropdown.hidden ) return;
            if ( e.key === 'ArrowDown' ) {
                e.preventDefault();
                activeIdx = Math.min( currentResults.length - 1, activeIdx + 1 );
                render( currentResults, lastQuery );
            } else if ( e.key === 'ArrowUp' ) {
                e.preventDefault();
                activeIdx = Math.max( -1, activeIdx - 1 );
                render( currentResults, lastQuery );
            } else if ( e.key === 'Enter' && activeIdx >= 0 ) {
                e.preventDefault();
                input.value = currentResults[ activeIdx ];
                close();
            } else if ( e.key === 'Escape' ) {
                e.preventDefault();
                close();
            }
        } );
    }

    // Parse Template:Commons FP galleries into a map of gallery path β†’ icon
    // filename so the tree browser can show the same visual cue Commons uses
    // in its index navbox. Cached at the module level so repeated dialog opens
    // don't re-fetch.
    function loadFpgIconMap( api ) {
        if ( fpgIconMapPromise ) return fpgIconMapPromise;
        fpgIconMapPromise = api.get( {
            action: 'query',
            titles: 'Template:Commons FP galleries',
            prop: 'revisions',
            rvprop: 'content', rvslots: 'main',
            format: 'json', formatversion: 2
        } ).then( function ( res ) {
            var p = ( res && res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
            var wt = ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots && p.revisions[ 0 ].slots.main && p.revisions[ 0 ].slots.main.content ) || '';
            var map = {};
            // Match `[\u005bFile:<icon>|...]] [[Commons:Featured pictures/<path>|...]]`
            // (the row format the template uses for every link). First match
            // wins so the most prominent listing of a path takes precedence.
            var re = /\[\[\s*File\s*:\s*([^|\]\n]+?)\s*\|[^\]\n]*\]\]\s*\[\[\s*Commons:Featured pictures\/([^|\]\n]+?)\s*(?:\||\]\])/g;
            var m;
            while ( ( m = re.exec( wt ) ) ) {
                var icon = m[ 1 ].trim();
                var path = m[ 2 ].trim();
                if ( icon && path && !map[ path ] ) map[ path ] = icon;
            }
            return map;
        } ).catch( function () { return {}; } );
        return fpgIconMapPromise;
    }

    // Build the Special:FilePath URL for a Commons file at a target width.
    // Used by the tree browser to render the navbox icon next to each row.
    function commonsThumbUrl( filename, width ) {
        return 'https://commons.wikimedia.org/wiki/Special:FilePath/' +
            encodeURIComponent( filename ) + '?width=' + ( width || 20 );
    }

    // Fetch a small batch of representative images from the given FP gallery
    // path. Two modes:
    //   β€’ No section anchor β†’ grab images from the whole page in one round
    //     trip with generator=images.
    //   β€’ With section anchor (e.g. "Animals/Amphibians#Gallery of Amphibia") β†’
    //     look up the section index, then ask action=parse for just that
    //     section's images so we only sample the relevant subset (not every
    //     image on the parent page).
    // Filter out SVG icons + tiny files so navbox icons / nav arrows don't
    // pollute the grid. Results cached per (path, section?) combo.
    var fpgSamplesCache = {};
    function fetchGallerySamples( galleryPath, sectionAnchor, api ) {
        // Back-compat: callers used to pass (path, api) when there was no
        // section concept. Detect and shift.
        if ( sectionAnchor && typeof sectionAnchor !== 'string' && !api ) {
            api = sectionAnchor;
            sectionAnchor = null;
        }
        var cacheKey = galleryPath + ( sectionAnchor ? '#' + sectionAnchor : '' );
        if ( fpgSamplesCache[ cacheKey ] ) return Promise.resolve( fpgSamplesCache[ cacheKey ] );
        var pageTitle = 'Commons:Featured pictures/' + galleryPath;
        function postFilter( samples ) {
            return samples.filter( function ( s ) {
                if ( !s.thumb ) return false;
                if ( /svg/.test( s.mime || '' ) ) return false;
                if ( ( s.width || 0 ) < 200 ) return false;
                return true;
            } ).slice( 0, 12 );
        }
        function whole() {
            return api.get( {
                action: 'query',
                titles: pageTitle,
                generator: 'images',
                gimlimit: 100,
                prop: 'imageinfo',
                iiprop: 'url|size|mime',
                iiurlwidth: 150,
                format: 'json', formatversion: 2
            } ).then( function ( res ) {
                var pages = ( res && res.query && res.query.pages ) || [];
                return postFilter( pages.map( function ( p ) {
                    var ii = ( p.imageinfo && p.imageinfo[ 0 ] ) || {};
                    return {
                        title: p.title,
                        thumb: ii.thumburl,
                        url: ii.descriptionurl || ( '/wiki/' + encodeURIComponent( p.title ) ),
                        mime: ii.mime,
                        width: ii.width,
                        height: ii.height
                    };
                } ) );
            } );
        }
        function sectionImages( sectionIdx ) {
            return api.get( {
                action: 'parse',
                page: pageTitle,
                section: sectionIdx,
                prop: 'images',
                format: 'json', formatversion: 2
            } ).then( function ( r ) {
                var names = ( r && r.parse && r.parse.images ) || [];
                if ( !names.length ) return [];
                // imageinfo for the section\'s files (cap at 50, MW limit).
                var titles = names.slice( 0, 50 ).map( function ( n ) { return 'File:' + n; } );
                return api.get( {
                    action: 'query',
                    titles: titles.join( '|' ),
                    prop: 'imageinfo',
                    iiprop: 'url|size|mime',
                    iiurlwidth: 150,
                    format: 'json', formatversion: 2
                } ).then( function ( res ) {
                    var pages = ( res && res.query && res.query.pages ) || [];
                    return postFilter( pages.map( function ( p ) {
                        var ii = ( p.imageinfo && p.imageinfo[ 0 ] ) || {};
                        return {
                            title: p.title,
                            thumb: ii.thumburl,
                            url: ii.descriptionurl || ( '/wiki/' + encodeURIComponent( p.title ) ),
                            mime: ii.mime,
                            width: ii.width,
                            height: ii.height
                        };
                    } ) );
                } );
            } );
        }
        var resultPromise;
        if ( sectionAnchor ) {
            // Resolve section name β†’ index via prop=sections, then fetch.
            resultPromise = api.get( {
                action: 'parse',
                page: pageTitle,
                prop: 'sections',
                format: 'json', formatversion: 2
            } ).then( function ( r ) {
                var secs = ( r && r.parse && r.parse.sections ) || [];
                var target = String( sectionAnchor ).replace( /<[^>]+>/g, '' ).trim();
                var match = null;
                for ( var i = 0; i < secs.length; i++ ) {
                    var clean = String( secs[ i ].line || '' ).replace( /<[^>]+>/g, '' ).trim();
                    if ( clean === target || secs[ i ].anchor === target.replace( /\s+/g, '_' ) ) {
                        match = secs[ i ];
                        break;
                    }
                }
                if ( !match ) return whole(); // unknown section β†’ safe fallback
                return sectionImages( match.index );
            } ).catch( function () { return whole(); } );
        } else {
            resultPromise = whole();
        }
        return resultPromise.then( function ( samples ) {
            fpgSamplesCache[ cacheKey ] = samples;
            return samples;
        } );
    }

    function renderGallerySamples( samples, container, path, section ) {
        container.innerHTML = '';
        var hrefPath = '/wiki/Commons:Featured%20pictures/' + encodeURI( path ) +
            ( section ? '#' + encodeURIComponent( section ).replace( /%20/g, '_' ) : '' );
        var labelPath = path + ( section ? ' Β§ ' + section : '' );
        if ( !samples.length ) {
            container.innerHTML = '<div class="fpc-nominate-samples-empty">No images found in <a href="' +
                escapeAttr( hrefPath ) + '" target="_blank">this ' + ( section ? 'section' : 'gallery page' ) +
                '</a>. The path may be empty or new.</div>';
            return;
        }
        var grid = document.createElement( 'div' );
        grid.className = 'fpc-nominate-samples-grid';
        samples.forEach( function ( s ) {
            var a = document.createElement( 'a' );
            a.href = s.url;
            a.target = '_blank';
            a.rel = 'noopener';
            a.className = 'fpc-nominate-samples-thumb';
            a.title = s.title.replace( /^File:/, '' );
            var img = document.createElement( 'img' );
            img.src = s.thumb;
            img.loading = 'lazy';
            img.alt = '';
            a.appendChild( img );
            grid.appendChild( a );
        } );
        container.appendChild( grid );
        var meta = document.createElement( 'div' );
        meta.className = 'fpc-nominate-samples-meta';
        meta.innerHTML = 'Showing ' + samples.length + ' image' + ( samples.length === 1 ? '' : 's' ) +
            ' in <a href="' + escapeAttr( hrefPath ) + '" target="_blank">' + escapeAttr( labelPath ) + '</a>.';
        container.appendChild( meta );
    }

    // Fetch short Wikidata descriptions for a batch of taxon / topic names so
    // the tree browser can show "order of mammals" under "Carnivora", etc.
    // Resolves via the English Wikipedia sitelink, then reads the Wikidata
    // description for the en language. CORS-enabled, so we hit wikidata.org
    // directly from the script.
    var fpgDescCache = {};
    function fetchTaxonDescriptions( names ) {
        var titles = names.map( function ( n ) { return n.split( '/' ).pop(); } );
        var uncached = titles.filter( function ( t ) { return !( t in fpgDescCache ); } );
        if ( !uncached.length ) {
            var hit = {};
            titles.forEach( function ( t ) { hit[ t ] = fpgDescCache[ t ]; } );
            return Promise.resolve( hit );
        }
        var url = 'https://www.wikidata.org/w/api.php?' + new URLSearchParams( {
            action: 'wbgetentities',
            sites: 'enwiki',
            titles: uncached.join( '|' ),
            props: 'descriptions',
            languages: 'en',
            format: 'json',
            origin: '*'
        } ).toString();
        return fetch( url ).then( function ( r ) { return r.json(); } ).then( function ( d ) {
            var entities = d && d.entities || {};
            Object.keys( entities ).forEach( function ( q ) {
                var ent = entities[ q ];
                var label = ent.sitelinks && ent.sitelinks.enwiki && ent.sitelinks.enwiki.title;
                var desc = ent.descriptions && ent.descriptions.en && ent.descriptions.en.value;
                if ( label && desc ) fpgDescCache[ label ] = desc;
            } );
            // Cache misses as empty string so we don't refetch.
            uncached.forEach( function ( t ) {
                if ( !( t in fpgDescCache ) ) fpgDescCache[ t ] = '';
            } );
            var out = {};
            titles.forEach( function ( t ) { out[ t ] = fpgDescCache[ t ]; } );
            return out;
        } ).catch( function () {
            // Cache misses so we don't keep retrying a broken endpoint.
            uncached.forEach( function ( t ) { fpgDescCache[ t ] = ''; } );
            var out = {};
            titles.forEach( function ( t ) { out[ t ] = ''; } );
            return out;
        } );
    }

    // Smart pre-selection of the FP gallery that best matches the file. Walks
    // the file\'s topical categories up to two hops looking for any segment
    // that matches a known FP gallery leaf name (e.g., file in [\u005bCategory:
    // Felis catus]] β†’ parent has "Felidae" β†’ grandparent has "Carnivora",
    // which matches the gallery "Animals/Mammals/Carnivora"). Falls back to
    // null when nothing in two hops matches, caller can then drop to the
    // rule-based suggestGallery() heuristic.
    function smartSuggestGallery( catNames, iconMap, api ) {
        if ( !iconMap || !catNames || !catNames.length ) return Promise.resolve( null );
        // Build leaf-name β†’ full path index. Each iconMap key is a real FP
        // gallery path (e.g. "Animals/Mammals/Carnivora"); we key by the last
        // segment. When a leaf name exists under several tops (e.g. "People" is
        // both the top-level "People" gallery AND "Non-photographic
        // media/People") prefer the SHALLOWEST, most general path, the safe
        // guess. Preferring the deepest path used to send a portrait photo to
        // "Non-photographic media/People". Unique leaves (Carnivora,
        // Passeriformes, Cityscapes…) resolve the same either way.
        var leafIndex = {};
        var pathSet = {};
        var pathDepth = function ( p ) { return p.split( '/' ).length; };
        Object.keys( iconMap ).forEach( function ( path ) {
            pathSet[ path ] = true;
            var leaf = path.split( '/' ).pop();
            var prev = leafIndex[ leaf ];
            if ( !prev || pathDepth( path ) < pathDepth( prev ) ||
                ( pathDepth( path ) === pathDepth( prev ) && path.length < prev.length ) ) {
                leafIndex[ leaf ] = path;
            }
        } );
        // Pre-build a normalised-leaf index so contains-match doesn\'t cost a
        // full scan per category.
        var leafLowerIndex = {};
        Object.keys( leafIndex ).forEach( function ( leaf ) {
            leafLowerIndex[ leaf.toLowerCase() ] = leafIndex[ leaf ];
        } );
        // Candidate name variants generated from a category title. Includes
        // common Commons category patterns:
        //   "Paintings by Leonardo da Vinci"  β†’ "Paintings"
        //   "Mammals of Africa"               β†’ "Mammals"
        //   "Birds in flight"                 β†’ "Birds"
        //   "Plants from Spain"               β†’ "Plants"
        //   "Featured photographs of Carnivora" β†’ "Carnivora"
        function variantsOf( name ) {
            var out = [];
            function push( s ) { s = ( s || '' ).trim(); if ( s ) out.push( s ); }
            push( name );
            push( name.toLowerCase() );
            push( name.replace( /s$/, '' ) );
            push( name.replace( /\s+(?:photographs|pictures|photos|images)$/i, '' ) );
            // Strip common trailing phrases that wrap a leaf with provenance.
            var stripped = name.replace( /\s+(?:by|of|from|in|on|at)\s+.+$/i, '' );
            if ( stripped !== name ) push( stripped );
            // Strip common leading qualifiers.
            var unprefixed = name.replace( /^(?:Featured |Quality |Valued |Historical |Modern )/i, '' );
            if ( unprefixed !== name ) push( unprefixed );
            // Combine both prefixes / suffixes.
            var both = unprefixed.replace( /\s+(?:by|of|from|in|on|at)\s+.+$/i, '' );
            if ( both !== unprefixed && both !== name ) push( both );
            return out;
        }
        function tryMatch( cats ) {
            // Pass 1: exact leaf match using any variant.
            for ( var i = 0; i < cats.length; i++ ) {
                var c = cats[ i ];
                var vs = variantsOf( c );
                for ( var j = 0; j < vs.length; j++ ) {
                    var hit = leafIndex[ vs[ j ] ] || leafLowerIndex[ vs[ j ].toLowerCase() ];
                    if ( hit ) return hit;
                }
            }
            // Pass 2: substring match, pick the longest leaf name that
            // appears as a word inside the category. Bounded to leaves β‰₯ 5
            // chars so short noun matches ("Art", "Sea") don\'t fire on
            // unrelated cats. Picks the longest match if many fit.
            var bestHit = null;
            var bestLen = 0;
            for ( var k = 0; k < cats.length; k++ ) {
                var lcCat = ( ' ' + cats[ k ].toLowerCase() + ' ' );
                Object.keys( leafLowerIndex ).forEach( function ( leaf ) {
                    if ( leaf.length < 5 ) return;
                    var re = new RegExp( '\\b' + leaf.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ) + '\\b' );
                    if ( re.test( lcCat ) && leaf.length > bestLen ) {
                        bestHit = leafLowerIndex[ leaf ];
                        bestLen = leaf.length;
                    }
                } );
            }
            return bestHit;
        }
        // Level 0: file\'s own categories.
        var direct = tryMatch( catNames );
        if ( direct ) return Promise.resolve( direct );
        // Level 1: parent categories.
        return fetchParentCategories( catNames, api ).then( function ( parents ) {
            var lvl1 = tryMatch( parents );
            if ( lvl1 ) return lvl1;
            // Level 2: grandparent categories.
            return fetchParentCategories( parents, api ).then( function ( grandparents ) {
                var lvl2 = tryMatch( grandparents );
                if ( lvl2 ) return lvl2;
                // Level 3: great-grandparents (last resort). Most real-world
                // category trees on Commons are 4–5 levels above the leaf for
                // a species, so this final hop catches them.
                return fetchParentCategories( grandparents, api ).then( function ( ggp ) {
                    return tryMatch( ggp ) || null;
                } );
            } );
        } );
    }

    // Returns the (deduplicated) set of parent categories of the given list of
    // category names. One API call regardless of input size, capped at 50
    // titles per request (the MW limit) so we slice if a Wikidata-rich file
    // has more than that many topical cats.
    function fetchParentCategories( catNames, api ) {
        if ( !catNames || !catNames.length ) return Promise.resolve( [] );
        var slice = catNames.slice( 0, 50 ).map( function ( c ) { return 'Category:' + c; } );
        return api.get( {
            action: 'query',
            titles: slice.join( '|' ),
            prop: 'categories',
            cllimit: 'max',
            clshow: '!hidden',
            format: 'json', formatversion: 2
        } ).then( function ( res ) {
            var pages = ( res && res.query && res.query.pages ) || [];
            var seen = {};
            var out = [];
            pages.forEach( function ( p ) {
                ( p.categories || [] ).forEach( function ( c ) {
                    var name = ( c.title || '' ).replace( /^Category:/, '' );
                    if ( name && !seen[ name ] ) {
                        seen[ name ] = true;
                        out.push( name );
                    }
                } );
            } );
            return out;
        } ).catch( function () { return []; } );
    }

    // Tree browser for the Featured pictures gallery hierarchy. Drills into
    // Commons:Featured pictures/<X>/<Y>/… one level at a time, then offers
    // section anchors of the final page so the picked path lands directly on
    // the relevant subsection. allpages results are cached per-prefix so
    // repeat navigations don't re-hit the API.
    function setupGalleryTreeBrowser( tree, suggest, input, api, onNavigate ) {
        var cache = {}; // prefix -> { children: [paths], sections: [titles] }
        var current = ''; // empty = root (under Commons:Featured pictures/)
        var PREFIX = 'Featured pictures/';
        var FULL_PREFIX = 'Commons:' + PREFIX;
        var iconMap = null;
        var iconMapResolving = false;

        // Initial position: parent of the pre-filled suggestion so the user
        // sees siblings, most useful entry point. Empty input β†’ root.
        var startPath = ( input.value || '' ).split( '#' )[ 0 ].replace( /\/$/, '' );
        if ( startPath && startPath.indexOf( '/' ) >= 0 ) {
            startPath = startPath.slice( 0, startPath.lastIndexOf( '/' ) );
        } else {
            startPath = '';
        }
        navigate( startPath );
        // Initial samples preview should reflect the pre-filled value (input)
        // if any, otherwise the start path the tree opens at.
        if ( onNavigate ) onNavigate( ( input.value || '' ).split( '#' )[ 0 ] || startPath );
        // Icon-map fetch in parallel; re-renders the current level when ready.
        iconMapResolving = true;
        loadFpgIconMap( api ).then( function ( m ) {
            iconMap = m;
            if ( cache[ current ] ) renderLevel( current, cache[ current ] );
        } );

        function navigate( prefix ) {
            current = prefix;
            tree.innerHTML = '<div class="fpc-nominate-gallery-tree-loading">Loading…</div>';
            // Tell the host (right column) about the new level so it can
            // refresh its samples panel even though the input hasn\'t changed.
            if ( onNavigate ) onNavigate( prefix );
            fetchLevel( prefix ).then( function ( data ) {
                if ( current !== prefix ) return; // user navigated away
                renderLevel( prefix, data );
            } ).catch( function () {
                tree.innerHTML = '<div class="fpc-nominate-gallery-tree-empty">Could not load, try again.</div>';
            } );
        }

        function fetchLevel( prefix ) {
            if ( cache[ prefix ] ) return Promise.resolve( cache[ prefix ] );
            // Two concurrent calls: (1) child pages under this prefix via
            // allpages, (2) sections of the page AT this prefix (if it exists)
            // via parse, only meaningful when prefix is non-empty.
            var fullPrefix = FULL_PREFIX + ( prefix ? prefix + '/' : '' );
            var childrenP = api.get( {
                action: 'query',
                list: 'allpages',
                apprefix: PREFIX + ( prefix ? prefix + '/' : '' ),
                apnamespace: 4,
                aplimit: 500,
                format: 'json', formatversion: 2
            } ).then( function ( res ) {
                var pages = ( res && res.query && res.query.allpages ) || [];
                // Reduce to immediate-child segments only (Plants/Asterales,
                // not Plants/Asterales/Subfamily/etc.). Strip the FULL_PREFIX
                // and our current sub-prefix, then keep what comes before the
                // next "/".
                var seen = {};
                var children = [];
                // 2-3 letter all-lowercase segments are ISO-639 language codes
                // (de, es, fr, ja, ru, zh-…) for translated gallery indexes
                // maintained by each language community, NOT nomination
                // targets. Filter them out at the root level so the user
                // sees content galleries (Plants, Animals, …) only. Deeper
                // levels keep all children, they're already content paths.
                var isRoot = !prefix;
                var LANG_CODE_RE = /^[a-z]{2,3}(?:-[a-z0-9]+)?$/;
                // Skip subpages that aren\'t real gallery destinations:
                //   β€’ File:<name>.<ext>  β†’ someone created a subpage shaped
                //     like a file ref under Commons:Featured pictures/;
                //     selecting one as a gallery would write a broken link.
                //   β€’ Image:<name>       β†’ legacy alias for File:.
                //   β€’ <name>.<ext>       β†’ filename-shaped leaf with a media
                //     extension (jpg, png, …) but no namespace.
                //   β€’ Talk pages / candidate-list maintenance subpages.
                var NON_GALLERY_RE = /^(?:File|Image|Talk|Special|Category|User|Media|MediaWiki):/i;
                var MEDIA_LIKE_RE = /\.(?:jpg|jpeg|png|gif|svg|tif|tiff|webp|ogv|webm|pdf|djvu)$/i;
                pages.forEach( function ( p ) {
                    var rest = ( p.title || '' ).slice( fullPrefix.length );
                    if ( !rest ) return;
                    var nextSeg = rest.split( '/' )[ 0 ];
                    if ( !nextSeg || seen[ nextSeg ] ) return;
                    if ( isRoot && LANG_CODE_RE.test( nextSeg ) ) return;
                    if ( NON_GALLERY_RE.test( nextSeg ) ) return;
                    if ( MEDIA_LIKE_RE.test( nextSeg ) ) return;
                    seen[ nextSeg ] = true;
                    children.push( ( prefix ? prefix + '/' : '' ) + nextSeg );
                } );
                children.sort();
                return children;
            } );
            var sectionsP = prefix ? api.get( {
                action: 'parse',
                page: FULL_PREFIX + prefix,
                prop: 'sections',
                format: 'json', formatversion: 2
            } ).then( function ( res ) {
                var secs = ( res && res.parse && res.parse.sections ) || [];
                // sec.line may contain HTML (bold, italics, links). Strip tags
                // for display. Filter out "Gallery of <page>", that's the
                // page-level boilerplate heading, not a useful subsection
                // anchor (selecting it is equivalent to picking the page
                // without an anchor at all).
                var pageName = prefix.split( '/' ).pop();
                var redundantRe = new RegExp( '^\\s*Gallery of\\s+' + pageName.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ) + '\\s*$', 'i' );
                // Include headings up to level 3 (=== Subfamily : … ===) so
                // taxonomically-deeper FP galleries (Plants/Asterales has
                // Order > Family > Subfamily) are reachable. Level 4+ tends
                // to be noise (image counts, etc.) and is left out.
                return secs.filter( function ( s ) {
                    var lvl = parseInt( s.level, 10 );
                    return ( lvl >= 1 && lvl <= 3 );
                } )
                    .map( function ( s ) {
                        // Many FP gallery section headings render with a U+00A0
                        // non-breaking space (e.g. \"Subfamily : Foo\") which
                        // looks identical to a regular space on screen but
                        // would never match if the user typed a normal space
                        // into the gallery input. Normalize to plain spaces so
                        // both display and the committed anchor are consistent.
                        var raw = String( s.line || s.anchor || '' )
                            .replace( /<[^>]+>/g, '' )
                            .replace( /Β /g, ' ' )
                            .trim();
                        return raw;
                    } )
                    .filter( function ( t ) { return t && !redundantRe.test( t ); } );
            } ).catch( function () { return []; } ) : Promise.resolve( [] );
            return Promise.all( [ childrenP, sectionsP ] ).then( function ( r ) {
                cache[ prefix ] = { children: r[ 0 ], sections: r[ 1 ] };
                return cache[ prefix ];
            } );
        }

        function renderLevel( prefix, data ) {
            tree.innerHTML = '';
            // Breadcrumb. Each segment is clickable to drill back.
            var crumbs = document.createElement( 'div' );
            crumbs.className = 'fpc-nominate-gallery-tree-crumbs';
            var rootCrumb = document.createElement( 'a' );
            rootCrumb.href = '#';
            rootCrumb.textContent = 'Featured pictures';
            rootCrumb.addEventListener( 'click', function ( e ) { e.preventDefault(); navigate( '' ); } );
            crumbs.appendChild( rootCrumb );
            if ( prefix ) {
                var parts = prefix.split( '/' );
                var accum = '';
                parts.forEach( function ( p, i ) {
                    var sep = document.createElement( 'span' );
                    sep.textContent = ' / ';
                    sep.className = 'fpc-nominate-gallery-tree-sep';
                    crumbs.appendChild( sep );
                    accum = accum ? accum + '/' + p : p;
                    var a = document.createElement( 'a' );
                    a.href = '#';
                    a.textContent = p;
                    var localAccum = accum;
                    a.addEventListener( 'click', function ( e ) { e.preventDefault(); navigate( localAccum ); } );
                    crumbs.appendChild( a );
                } );
            }
            tree.appendChild( crumbs );

            // "Pick this page" shortcut when we're inside a leaf page.
            if ( prefix ) {
                var pickHere = document.createElement( 'button' );
                pickHere.type = 'button';
                pickHere.className = 'fpc-nominate-gallery-tree-pick-here';
                pickHere.textContent = 'βœ“ Use ' + prefix + ' (no section)';
                pickHere.addEventListener( 'click', function () { commit( prefix, '' ); } );
                tree.appendChild( pickHere );
            }

            // Body: two columns (subgalleries on left, sections on right)
            // when both are present; one column otherwise.
            var body = document.createElement( 'div' );
            body.className = 'fpc-nominate-gallery-tree-body';
            tree.appendChild( body );

            if ( data.children.length ) {
                var col1 = document.createElement( 'div' );
                col1.className = 'fpc-nominate-gallery-tree-col';
                var h1 = document.createElement( 'div' );
                h1.className = 'fpc-nominate-gallery-tree-colhead';
                h1.textContent = data.children.length + ' subgallery group' + ( data.children.length > 1 ? 'ies' : '' );
                col1.appendChild( h1 );
                data.children.forEach( function ( path ) {
                    var item = document.createElement( 'div' );
                    item.className = 'fpc-nominate-gallery-tree-item';
                    var name = path.split( '/' ).pop();
                    var label = escapeAttr( name );
                    // Prefer the navbox icon from Template:Commons FP galleries,
                    // it gives a visual cue (silhouette, emoji) that matches
                    // what Commons uses elsewhere. Fallback to πŸ“ if no entry.
                    var icon = iconMap && iconMap[ path ];
                    var iconHtml = icon
                        ? '<img class="fpc-nominate-gallery-tree-icon" src="' +
                            escapeAttr( commonsThumbUrl( icon, 20 ) ) + '" alt="" loading="lazy">'
                        : '<span class="fpc-nominate-gallery-tree-icon-fallback">πŸ“</span>';
                    item.innerHTML = iconHtml +
                        '<div class="fpc-nominate-gallery-tree-text">' +
                        '<div class="fpc-nominate-gallery-tree-name">' + label + '</div>' +
                        '<div class="fpc-nominate-gallery-tree-desc" data-name="' + escapeAttr( name ) + '"></div>' +
                        '</div>';
                    item.title = path;
                    item.addEventListener( 'click', function () { navigate( path ); } );
                    col1.appendChild( item );
                } );
                // Lazy-fetch one-line descriptions for every visible row in a
                // single Wikidata batch (re-used cache across navigations).
                var allNames = data.children.map( function ( p ) { return p.split( '/' ).pop(); } );
                if ( allNames.length ) {
                    fetchTaxonDescriptions( allNames ).then( function ( descMap ) {
                        col1.querySelectorAll( '.fpc-nominate-gallery-tree-desc' ).forEach( function ( el ) {
                            var n = el.getAttribute( 'data-name' );
                            var d = descMap[ n ];
                            if ( d ) el.textContent = d;
                        } );
                    } );
                }
                body.appendChild( col1 );
            }

            if ( data.sections.length ) {
                var col2 = document.createElement( 'div' );
                col2.className = 'fpc-nominate-gallery-tree-col';
                var h2 = document.createElement( 'div' );
                h2.className = 'fpc-nominate-gallery-tree-colhead';
                h2.textContent = data.sections.length + ' section' + ( data.sections.length > 1 ? 's' : '' );
                col2.appendChild( h2 );
                data.sections.forEach( function ( sec ) {
                    var item = document.createElement( 'div' );
                    item.className = 'fpc-nominate-gallery-tree-item fpc-nominate-gallery-tree-section';
                    item.innerHTML = 'πŸ“‘ ' + escapeAttr( sec );
                    item.title = prefix + '#' + sec;
                    item.addEventListener( 'click', function () { commit( prefix, sec ); } );
                    col2.appendChild( item );
                } );
                body.appendChild( col2 );
            }

            if ( !data.children.length && !data.sections.length ) {
                var empty = document.createElement( 'div' );
                empty.className = 'fpc-nominate-gallery-tree-empty';
                empty.textContent = 'This level has no subgalleries or sections. Use the breadcrumb to back out.';
                tree.appendChild( empty );
            }
        }

        function commit( prefix, section ) {
            input.value = section ? ( prefix + '#' + section ) : prefix;
            input.dispatchEvent( new Event( 'change' ) );
        }
    }

    // Post a courtesy notification on the uploader's User talk page (in English).
    function postTalkNotification( uploader, filePageName, fileTitle, subpageTitle, api ) {
        var section = 'Featured picture nomination of ' + fileTitle;
        var text =
            'Hi \u007b{ping|' + uploader + '}}, just letting you know that I have nominated your image [[:' + filePageName + '|' + fileTitle + ']] ' +
            'for Featured Picture status. You can follow the discussion and (if you wish) participate at ' +
            '[[' + subpageTitle + ']]. The voting period is nine days; criticism is normal and not personal. ' +
            'Cheers, --\u007e\u007e\u007e\u007e';
        return api.postWithToken( 'csrf', {
            action: 'edit',
            title: 'User talk:' + uploader,
            section: 'new',
            sectiontitle: section,
            text: text,
            summary: 'Notifying FPC nomination of [[:' + filePageName + ']] (via [[User:Wilfredor/fpc-archiver.js|fpc-archiver]])',
            assert: 'user',
            formatversion: 2
        } );
    }

    function submitNomination( filePageName, fileTitle, user, gallery, comment, selfSupport, attribution, api ) {
        var subpageTitle = 'Commons:Featured picture candidates/' + filePageName;
        var content = buildNominationTemplate( filePageName, fileTitle, user, gallery, comment, selfSupport, attribution );
        // Re-check rule 11 HERE, not only when the dialog opened. The FP dialog
        // carries a gallery tree browser and a readiness panel, so users sit in
        // it for minutes, long enough to file another nomination from a second
        // tab, or for one to have been filed already. QIC and VIC both re-check
        // at submit; FP was the exception. A failed read rejects rather than
        // reading as "0 active nominations".
        return fetchActiveNomsByUser( user, api ).then( function ( activeNoms ) {
            var stillFar = activeNoms.filter( function ( n ) { return n.daysOld < 7; } );
            if ( stillFar.length >= 2 ) {
                throw new Error( 'FPC rule 11 caps active nominations: you already have ' +
                    stillFar.length + '. Wait until one closes (or is within 2 days of closing).' );
            }
            return api.postWithToken( 'csrf', {
                action: 'edit',
                title: subpageTitle,
                text: content,
                summary: 'Nominating for FP (via [[User:Wilfredor/commons-nominator.js|commons-nominator]])',
                createonly: 1,
                assert: 'user',
                formatversion: 2
            } );
        } ).then( function () { return subpageTitle; } );
    }

    function addTransclusionToCandidateList( subpageTitle, api ) {
        // The candidate list explicitly asks for new noms at the TOP of the
        // section ("DO NOT ADD NEW NOMINATIONS RIGHT ABOVE THIS LINE, BUT AT
        // THE TOP OF THIS SECTION"). Appending at end was the previous
        // behaviour and put new noms below the line. Now: fetch the page,
        // insert right after the section heading. Underscores in subpageTitle
        // are normalised to spaces to match the convention used by every
        // other transclusion in the list.
        var pretty = subpageTitle.replace( /_/g, ' ' );
        var insertLine = '\u007b{' + pretty + '}}';
        return api.get( {
            action: 'query', titles: 'Commons:Featured picture candidates/candidate list',
            prop: 'revisions', rvprop: 'content|ids|timestamp', rvslots: 'main',
            format: 'json', formatversion: 2, curtimestamp: 1
        } ).then( function ( res ) {
            var p = ( res && res.query && res.query.pages && res.query.pages[ 0 ] ) || {};
            var rev = ( p.revisions && p.revisions[ 0 ] ) || {};
            var wt = ( rev.slots && rev.slots.main && rev.slots.main.content ) || '';
            if ( !wt ) throw new Error( 'Could not read candidate list.' );
            // Idempotency: if the transclusion is already there, leave the
            // list alone (the postClose / archive flow may re-call this on
            // an already-completed run).
            // Build the dup matcher from the FULL subpage title (space/underscore
            // tolerant). The previous version tried to strip the prefix with a
            // regex that expected an escaped slash and never matched, so it
            // doubled the prefix and the idempotency check never fired.
            var dupRe = new RegExp( '\\{\\{\\s*' +
                pretty.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ).replace( /[ _]/g, '[ _]' ) +
                '\\s*\\}\\}' );
            if ( dupRe.test( wt ) ) return { skipped: true };
            // Insert immediately after the section heading "== Featured
            // picture candidates ==" so the new nom is the FIRST entry.
            var headingRe = /(== *Featured picture candidates *==\s*\n)/;
            var newText;
            if ( headingRe.test( wt ) ) {
                // Replacer function: insertLine embeds the file name, which may
                // contain "$" sequences that a replacement string would expand.
                newText = wt.replace( headingRe, function ( m, heading ) { return heading + insertLine + '\n'; } );
            } else {
                // Fallback: prepend at the very top (safer than appending at
                // the end where the "DO NOT ADD" comment lives).
                newText = insertLine + '\n' + wt;
            }
            return api.postWithToken( 'csrf', {
                action: 'edit',
                title: 'Commons:Featured picture candidates/candidate list',
                text: newText,
                summary: 'Adding [[' + pretty + ']] to candidate list ' +
                    '(via [[User:Wilfredor/commons-nominator.js|commons-nominator]])',
                // Pin the edit. Without this a nomination filed by someone else
                // between our read and this write is silently overwritten, its
                // transclusion vanishes while its subpage lives on. Every other
                // list writer here (QIC, VIC, en.wp) already pins; this was the
                // one that didn't.
                baserevid: rev.revid,
                basetimestamp: rev.timestamp,
                starttimestamp: res.curtimestamp,
                nocreate: 1,
                assert: 'user',
                formatversion: 2
            } );
        } );
    }

    function buildNominationTemplate( filePageName, fileTitle, user, gallery, comment, selfSupport, attribution ) {
        // wgPageName arrives with underscores; the canonical FPC nomination
        // template uses spaces everywhere so the heading renders as
        // \"File:Name with spaces.jpg\" rather than \"File:Name_with_…\".
        var pretty = filePageName.replace( /_/g, ' ' );
        var subpage = 'Commons:Featured picture candidates/' + pretty;
        var nineDaysSecs = 9 * 24 * 3600;
        var endTs = Math.floor( Date.now() / 1000 ) + nineDaysSecs;
        var sig = '--\u007e\u007e\u007e\u007e';
        var infoParts = [ '\u007b{Info}}' ];
        if ( comment ) infoParts.push( comment );
        if ( attribution ) infoParts.push( attribution );
        infoParts.push( sig );
        // Standard FPC nomination format used by every other open nom (the
        // wpImageAnnotator wrapper makes the image render centred + enables
        // the image-notes gadget; without it the image floats left and the
        // page looks broken). FPVotingPeriodFlag carries the unix timestamp
        // when the 9-day voting period ends, FPCBot reads this for its
        // expiry checks.
        var lines = [];
        lines.push( '===[[:' + pretty + ']]===' );
        lines.push(
            '\u007b{FPVotingPeriodFlag|' + endTs + '}}' +
            '<small>Voting period ends on <b>' + new Date( endTs * 1000 ).toUTCString().replace( / GMT$/, ' (UTC)' ) +
            '</b> (unless closed by the [[COM:FPC#5th-day|5th-day rule]])</small><br>' +
            "<small>Voters ''must'' check: [[COM:FN|File name]]\u007b{Β·}}[[COM:IG|Quality]]\u007b{Β·}}" +
            '[[Template:Information#Template parameters|Image description]]\u007b{Β·}}' +
            '[[Commons:Copyright tags|License]]\u007b{Β·}}[[COM:CAT#Categorization tips|Categories]] ' +
            '(what, where, who, when)</small><br>'
        );
        lines.push(
            'Visit the [[' + subpage + '|nomination page]] to add or modify ' +
            '[[Help:Gadget-ImageAnnotator|image notes]].'
        );
        lines.push( '<div class="wpImageAnnotatorEnable">' );
        lines.push(
            '<span class="wpImageAnnotatorPageName" style="display:none;">' +
            'Featured picture candidates/' + pretty + '</span>'
        );
        lines.push(
            '<span class="wpImageAnnotatorFullName" style="display:none;">' +
            subpage + '</span>'
        );
        lines.push(
            '<div class="wpImageAnnotatorFile">[[' + pretty + '|500x320px|' +
            escapeAttr( fileTitle ) + ']]</div>'
        );
        lines.push(
            '<div style="display:none;"><div><div>' +
            '<!--Dummy marker to have image notes inserted below this line-->' +
            '\u007b{ImageNoteEnd|id=-1}}'
        );
        lines.push( '</div>' );
        lines.push( '</div>' );
        lines.push( '' );
        if ( gallery ) {
            lines.push( "*'''Gallery:''' [[Commons:Featured pictures/" + gallery + ']]' );
        } else {
            lines.push( "*'''Gallery:''' <!-- add gallery link, e.g. [[Commons:Featured pictures/Plants/Asterales#Subfamily : Asteroideae]] -->" );
        }
        lines.push( '*' + infoParts.join( ' ' ) );
        if ( selfSupport ) {
            lines.push( '*\u007b{Support}} ' + sig );
        }
        // Trailing newline matters: when this subpage is transcluded directly
        // above the next nom on the candidate list, MediaWiki needs a hard
        // line break between Cosmos\'s last `*\u007b{Support}} sig` line and the
        // next nom\'s `===File:…===` heading. Without it, the following
        // heading is parsed as inline content of Cosmos\'s last list item and
        // the next nom\'s title disappears from the rendered FPC page.
        return lines.join( '\n' ) + '\n';
    }

    // ─── Quality Image Candidates (QIC) ─────────────────────────────────────
    // QIC nomination = ONE gallery line under today's UTC date heading on a
    // single shared list page. Hard rule: no more than 5 images per day per
    // nominator. There is no per-nomination subpage and no open-nomination cap.

    function qicTodayHeadingText() {
        var d = new Date();
        return MONTHS[ d.getUTCMonth() ] + ' ' + d.getUTCDate() + ', ' + d.getUTCFullYear();
    }

    // Whitespace-tolerant, line-anchored matcher for today's date heading.
    // MediaWiki does NOT normalise heading spacing, so "==June 13, 2026=="
    // and "== June 13, 2026 ==" both persist; an exact-spaced indexOf would
    // miss a hand-spaced section, return 0 noms, and let the 5/day cap be
    // silently exceeded (plus create a duplicate section). The date text is
    // letters/digits/comma/space only, so it is regex-safe verbatim.
    function qicTodayHeadingRe() {
        return new RegExp( '^==[ \\t]*' + qicTodayHeadingText() + '[ \\t]*==[ \\t]*$', 'm' );
    }

    // True if the file already has a line on the QIC candidate list (open
    // nomination or one awaiting QICbot archival).
    //
    // The namespace is OPTIONAL. The list is a gallery, where the File:
    // prefix may be left off, and five of the 644 nomination lines live on
    // 2026-08-27 were written that way. Requiring the prefix answered "not
    // nominated" for every one of them, and that is the answer that lets a
    // second nomination be posted over an open one.
    function qicAlreadyNominated( wt, filePageName ) {
        var name = filePageName.replace( /^File:/, '' );
        var esc = name.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ).replace( /[ _]/g, '[ _]' );
        var re = new RegExp(
            '\\n\\s*(?:(?:File|Image)[ _]?:[ _]?)?' + esc + '\\s*\\|\\s*\\{\\{\\s*/', 'i' );
        return re.test( '\n' + wt );
    }

    // Count the user's OWN nominations under today's (UTC) date section. Reads
    // parameter 1 (the nominator signature) only, so the user's reviews of
    // other people's nominations (which sign parameter 2) do NOT inflate the
    // count and falsely block them.
    function countQicNomsToday( wt, user ) {
        var mh = qicTodayHeadingRe().exec( wt );
        if ( !mh ) return 0;
        var rest = wt.slice( mh.index + mh[ 0 ].length );
        var nextSec = rest.search( /\n==[^=]/ );
        var section = nextSec >= 0 ? rest.slice( 0, nextSec ) : rest;
        var canon = canonUser( user );
        var count = 0;
        section.split( /\n/ ).forEach( function ( line ) {
            // Count ANY relative subpage template (\u007b{/Nomination}}, \u007b{/Promotion}},
            // \u007b{/Decline}}, \u007b{/Discuss}}, \u007b{/Withdrawn}}, future variants…) whose
            // parameter 1 holds the user's signature. Enumerating verdict names
            // drifts: a withdrawn-but-same-day nom keeps the nominator sig in
            // param 1 and still counts toward the 5/day budget, so missing it
            // would let the user post a 6th.
            if ( !/\{\{\s*\/[A-Za-z]/.test( line ) ) return;
            var p1 = firstTemplateParam1( line );
            if ( p1 && fragmentHasUser( p1, canon ) ) count++;
        } );
        return count;
    }

    function setupQicNominateLink( api ) {
        var pageName = mw.config.get( 'wgPageName' ) || '';
        var user = mw.config.get( 'wgUserName' );
        if ( !user || !/^File:/.test( pageName ) ) return;
        var link = addFileActionLink( 'pt-qic-nominate', 'πŸ…€ Nominate for QI',
            'Nominate this file for Quality Image (max 5 per day)' );
        if ( !link ) return;
        // Soft styling hint once we know the assessment + own-work state. Grey
        // the link out when the file is already a Quality Image OR is not the
        // current user's own work (QIC is limited to your own work).
        Promise.all( [
            checkFileAssessments( pageName, api ),
            checkFileOwnWork( pageName, user, api )
        ] ).then( function ( r ) {
            var a = r[ 0 ], ow = r[ 1 ];
            var li = link.closest( 'li' ) || link;
            if ( a.isQI ) {
                li.classList.add( 'cn-already', 'cn-disabled' );
                link.setAttribute( 'aria-disabled', 'true' );
                link.title = 'Inactive, this file is already a Quality Image' +
                    ( a.qiCategory ? ' (in "' + a.qiCategory + '").' : '.' );
            } else if ( ow && ow.isOwn === false ) {
                li.classList.add( 'cn-notown', 'cn-disabled' );
                link.setAttribute( 'aria-disabled', 'true' );
                link.title = 'Inactive, Quality Image nominations are limited to your own work' +
                    ownWorkReason( ow );
            }
        } ).catch( function () {} );

        link.addEventListener( 'click', function ( e ) {
            e.preventDefault();
            // Inactive when the file is already a Quality Image, bail out BEFORE
            // any "Checking…"/network, so the link does nothing at all (like the
            // FP link, which doesn't act on already-featured files).
            if ( ( link.closest( 'li' ) || link ).classList.contains( 'cn-disabled' ) ) return;
            var old = link.textContent;
            link.textContent = 'Checking…';
            link.style.pointerEvents = 'none';
            Promise.all( [
                checkFileAssessments( pageName, api ),
                fetchPage( QIC_LIST, api ),
                checkFileOwnWork( pageName, user, api )
            ] ).then( function ( r ) {
                link.textContent = old;
                link.style.pointerEvents = '';
                var assess = r[ 0 ];
                var list = r[ 1 ];
                var ow = r[ 2 ];
                if ( assess.isQI ) {
                    // Safety net (in case the class wasn't applied yet). Still inert.
                    ( link.closest( 'li' ) || link ).classList.add( 'cn-already', 'cn-disabled' );
                    return;
                }
                if ( ow && ow.isOwn === false ) {
                    // Own-work gate: QIC is limited to your own work. Disable the
                    // link too so a second click is inert (matches the already-QI
                    // path). Only reached when the load-time check hadn't resolved
                    // yet, otherwise the cn-disabled early-return above fires.
                    ( link.closest( 'li' ) || link ).classList.add( 'cn-notown', 'cn-disabled' );
                    alert( 'Cannot nominate for Quality Image: QIC nominations are limited to your own work' +
                        ownWorkReason( ow ) );
                    return;
                }
                if ( ow && ow.unknown ) {
                    // Fail CLOSED at the action. checkFileOwnWork deliberately
                    // resolves { isOwn:true, unknown:true } on a read error so a
                    // network blip never greys the link with a false "not your
                    // own work" claim, but letting that same unknown through
                    // here would nominate someone else's photo on one failed
                    // request. Same stance as the rule-11 guard, which also
                    // refuses rather than reading an error as "0 nominations".
                    alert( 'Cannot nominate for Quality Image: the file page could not be read, ' +
                        'so its authorship could not be verified. Please try again in a moment.' );
                    return;
                }
                if ( qicAlreadyNominated( list.wt, pageName ) ) {
                    alert( 'This file already has an open QIC nomination on the candidate list.' );
                    return;
                }
                var todayCount = countQicNomsToday( list.wt, user );
                if ( todayCount >= 5 ) {
                    alert( 'Cannot nominate. QIC allows no more than 5 images per day per nominator, ' +
                        'and you already have ' + todayCount + ' under today\'s (UTC) date section.' );
                    return;
                }
                showQicDialog( pageName, user, todayCount, api );
            } ).catch( function () {
                link.textContent = old;
                link.style.pointerEvents = '';
                alert( 'Failed to check QIC preconditions (network or API error). Please retry in a moment.' );
            } );
        } );
    }

    function showQicDialog( filePageName, user, todayCount, api ) {
        var fileTitle = filePageName.replace( /^File:/, '' ).replace( /_/g, ' ' );
        var m = buildModal( 'Nominate <i>' + escapeAttr( fileTitle ) + '</i> for Quality Image' );
        m.body.innerHTML =
            '<section class="fpc-nominate-section">' +
            '  <div class="fpc-nominate-section-title">Short description</div>' +
            '  <textarea class="cn-qic-desc" placeholder="One short line shown next to the thumbnail, e.g. &quot;Bell tower of the Church of San Fiz de Solovio, Santiago de Compostela (Spain)&quot;."></textarea>' +
            '  <div class="cn-hint">Quality Image is assessed by a single reviewer against ' +
            '    <a href="https://commons.wikimedia.org/wiki/Commons:Image_guidelines" target="_blank">the image guidelines</a> ' +
            '    (technical quality: focus, exposure, composition). It is independent of FP and VI.</div>' +
            '</section>' +
            '<section class="fpc-nominate-section">' +
            '  <div class="cn-quota">You have <b>' + todayCount + ' / 5</b> QIC nominations under today\'s (UTC) date section.</div>' +
            '</section>';
        var descInput = m.body.querySelector( '.cn-qic-desc' );
        descInput.focus();

        // Prefill with a trimmed version of the file's English description so the
        // nominator starts from real text instead of a blank box.
        descInput.placeholder = 'Loading the file\'s English description…';
        fetchEnglishDescription( filePageName, api ).then( function ( d ) {
            descInput.placeholder = 'One short line shown next to the thumbnail.';
            if ( d && !descInput.value.trim() ) {
                descInput.value = truncateDescription( d, 240 );
                descInput.setSelectionRange( descInput.value.length, descInput.value.length );
            }
        } );

        m.submitBtn.addEventListener( 'click', function () {
            var clean = sanitizeWikiField( descInput.value );
            if ( !clean.ok ) {
                m.status.innerHTML = '<span style="color:#b91c1c">The description has ' +
                    escapeAttr( clean.error ) + 'please fix it before submitting.</span>';
                descInput.focus();
                return;
            }
            m.submitBtn.disabled = true;
            m.cancelBtn.disabled = true;
            m.status.textContent = 'Adding to the QIC candidate list…';
            var line = buildQicLine( filePageName, clean.value, user );
            insertQicLine( filePageName, line, user, api ).then( function () {
                m.status.textContent = 'Done! Opening the QIC candidate list…';
                setTimeout( function () {
                    window.location.href = '/wiki/' + encodeURI( QIC_LIST.replace( / /g, '_' ) );
                }, 1000 );
            } ).catch( function ( err ) {
                var msg = err && err.error ? ( err.error.code + ': ' + err.error.info )
                    : ( err && err.message ? err.message : String( err ) );
                m.status.innerHTML = '<span style="color:#b91c1c"><b>Error:</b> ' + escapeAttr( msg ) + '</span>';
                m.submitBtn.disabled = false;
                m.cancelBtn.disabled = false;
            } );
        } );
    }

    // Build the gallery line. The description and the nominator signature are
    // parameter 1; parameter 2 is left EMPTY for the reviewer's verdict. The
    // signature \u007e\u007e\u007e\u007e is sent literally and expanded server-side by PST when
    // this edit saves (the file source is escaped at upload so PST does not
    // sign the .js page itself).
    // `cleanDescription` MUST already be sanitised (balanced, top-level pipes
    // neutralised) by sanitizeWikiField, the dialog does that and surfaces an
    // error to the user on invalid input, so an unbalanced "}}" can never reach
    // here and close \u007b{/Nomination}} early.
    function buildQicLine( filePageName, cleanDescription, user ) {
        var pretty = filePageName.replace( /_/g, ' ' );
        var desc = ( cleanDescription || '' ).trim();
        if ( !desc ) {
            // Commons titles cannot contain { } [ ] | < > so the filename
            // fallback is inherently safe to interpolate.
            desc = pretty.replace( /^File:/, '' ).replace( /\.[^.]+$/, '' );
        }
        // Sign CLIENT-SIDE: a plain [[User:X|X]] link plus a UTC timestamp we
        // format ourselves. We deliberately do NOT sign with tildes here. This
        // nomination rewrites the whole candidate-list page with a `text=`
        // edit, and on that edit the server pre-save transform was observed NOT
        // to expand the signature tildes, so a literal five-tilde run was saved
        // into the line (the nominator signature then showed as raw tildes
        // instead of a timestamp). Formatting the timestamp ourselves makes the
        // signature correct regardless of PST. A plain link (not the user's
        // *custom* signature, whose nicknames / <span>s / int-message talk link
        // render badly inside \u007b{/Nomination}} in a \u003cgallery mw-notalk> caption)
        // is the QIC convention and is what the daily-count parser keys off.
        var sig = '--[[User:' + user + '|' + user + ']] ' + qicSignatureTimestamp();
        return pretty + '|\u007b{/Nomination|' + desc + ' ' + sig + ' |}}';
    }

    // Current UTC time in MediaWiki's signature format, e.g.
    // "07:05, 6 July 2026 (UTC)". Zero-padded 2-digit hour and minute, day
    // without a leading zero, full month name. Matches what a five-tilde
    // timestamp expands to, so scripted QIC signatures read like hand-signed
    // ones. (Uses the browser clock in UTC, as the date-section heading
    // already does, so the timestamp and the section stay consistent.)
    function qicSignatureTimestamp() {
        var d = new Date();
        function p2( n ) { return ( n < 10 ? '0' : '' ) + n; }
        return p2( d.getUTCHours() ) + ':' + p2( d.getUTCMinutes() ) + ', ' +
            d.getUTCDate() + ' ' + MONTHS[ d.getUTCMonth() ] + ' ' +
            d.getUTCFullYear() + ' (UTC)';
    }

    // Fetch the file's ENGLISH description (extmetadata, language=en), as plain
    // text (HTML stripped), used to prefill the QIC description field.
    function fetchEnglishDescription( filePageName, api ) {
        return api.get( {
            action: 'query', prop: 'imageinfo', iiprop: 'extmetadata',
            iiextmetadatalanguage: 'en', titles: filePageName, format: 'json', formatversion: 2
        } ).then( function ( r ) {
            var pages = r && r.query && r.query.pages;
            var ii = pages && pages[ 0 ] && pages[ 0 ].imageinfo && pages[ 0 ].imageinfo[ 0 ];
            var raw = ii && ii.extmetadata && ii.extmetadata.ImageDescription && ii.extmetadata.ImageDescription.value;
            if ( !raw ) return '';
            var tmp = document.createElement( 'div' );
            tmp.innerHTML = raw;
            return ( tmp.textContent || tmp.innerText || '' ).replace( /\s+/g, ' ' ).trim();
        }, function () { return ''; } );
    }

    // The file's own (non-hidden) categories, offered as the VIC scope dropdown.
    function fetchFileCategories( filePageName, api ) {
        return api.get( {
            action: 'query', prop: 'categories', clshow: '!hidden', cllimit: 'max',
            titles: filePageName, format: 'json', formatversion: 2
        } ).then( function ( r ) {
            var p = r && r.query && r.query.pages && r.query.pages[ 0 ];
            return ( ( p && p.categories ) || [] ).map( function ( c ) {
                return c.title.replace( /^Category:/, '' );
            } );
        }, function () { return []; } );
    }

    // Trim a description to one short line, preferring a sentence boundary.
    function truncateDescription( text, max ) {
        max = max || 240;
        text = ( text || '' ).trim();
        if ( text.length <= max ) return text;
        var cut = text.slice( 0, max );
        var sentEnd = Math.max( cut.lastIndexOf( '. ' ), cut.lastIndexOf( '! ' ), cut.lastIndexOf( '? ' ) );
        if ( sentEnd > max * 0.5 ) return cut.slice( 0, sentEnd + 1 ).trim();
        var sp = cut.lastIndexOf( ' ' );
        return ( sp > 0 ? cut.slice( 0, sp ) : cut ).trim() + '…';
    }

    // ─── Picture of the Day (POTD) ──────────────────────────────────────────
    // Per Commons:Picture of the day/Instructions, anyone may put a Featured
    // Picture that was never POTD on the next FREE date. Three edits:
    //   1. Template:Potd/<date>          -> \u007b{Potd filename|1=<name>|...}}
    //   2. Template:Potd/<date> (en)     -> \u007b{Potd description|...}}
    //   3. the file page                 -> \u007b{Picture of the day|year=|month=|day=}}
    var POTD_PREFIX = 'Template:Potd/';

    function potd2( n ) { return ( n < 10 ? '0' : '' ) + n; }
    function potdDateStr( d ) {
        return d.getUTCFullYear() + '-' + potd2( d.getUTCMonth() + 1 ) + '-' + potd2( d.getUTCDate() );
    }

    // The file name currently sitting in the slot ('' when empty or absent),
    // normalised the way the page title is (underscores β†’ spaces).
    function potdFilenameOf( wt ) {
        var m = /\{\{\s*Potd filename\s*\|([\s\S]*?)\}\}/i.exec( wt || '' );
        if ( !m ) return '';
        var body = m[ 1 ];
        var nm = /(?:^|\|)\s*1\s*=\s*([\s\S]*?)(?=\||$)/.exec( body );
        var p1 = nm ? nm[ 1 ] : body.split( '|' )[ 0 ];
        return p1.replace( /<!--[\s\S]*?-->/g, '' ).replace( /_/g, ' ' ).trim();
    }

    // A Potd/<date> page whose filename slot is empty is free to fill.
    function potdFilenameEmpty( wt ) {
        var m = /\{\{\s*Potd filename\s*\|([\s\S]*?)\}\}/i.exec( wt || '' );
        if ( !m ) return false;
        var body = m[ 1 ];
        var nm = /(?:^|\|)\s*1\s*=\s*([\s\S]*?)(?=\||$)/.exec( body );
        var p1 = nm ? nm[ 1 ] : body.split( '|' )[ 0 ];
        return p1.replace( /<!--[\s\S]*?-->/g, '' ).trim() === '';
    }

    // Scan forward (batched) for the first date with a free POTD slot. POTD is
    // scheduled YEARS ahead on Commons (huge FP backlog, ~365 slots/year), so
    // the first free date is often ~2 years out, scan wide enough to reach it.
    function findNextFreePotdDate( api ) {
        var BATCH = 50, MAX = 1100;
        var base = new Date();
        base.setUTCHours( 0, 0, 0, 0 );
        var scanned = 0;
        function batch() {
            var dates = [];
            for ( var i = 0; i < BATCH && scanned < MAX; i++, scanned++ ) {
                dates.push( potdDateStr( new Date( base.getTime() + ( scanned + 1 ) * 86400000 ) ) );
            }
            if ( !dates.length ) return Promise.resolve( null );
            var titles = dates.map( function ( ds ) { return POTD_PREFIX + ds; } );
            return api.get( {
                action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main',
                titles: titles.join( '|' ), format: 'json', formatversion: 2
            } ).then( function ( r ) {
                var by = {};
                ( ( r.query && r.query.pages ) || [] ).forEach( function ( p ) {
                    by[ p.title ] = p.missing ? { missing: true } :
                        { wt: ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots.main.content ) || '' };
                } );
                for ( var j = 0; j < dates.length; j++ ) {
                    var info = by[ POTD_PREFIX + dates[ j ] ] || { missing: true };
                    if ( info.missing || potdFilenameEmpty( info.wt ) ) return dates[ j ];
                }
                return batch();
            } );
        }
        return batch();
    }

    function setupPotdLink( api ) {
        var pageName = mw.config.get( 'wgPageName' ) || '';
        var user = mw.config.get( 'wgUserName' );
        if ( !user || !/^File:/.test( pageName ) ) return;
        var link = addFileActionLink( 'pt-potd-set', 'πŸ—“ Make Picture of the Day',
            'Schedule this featured picture as Picture of the Day on the next free date' );
        if ( !link ) return;
        var li = link.closest( 'li' ) || link;
        li.style.display = 'none'; // only an FP that was never POTD qualifies
        Promise.all( [ checkFileAssessments( pageName, api ), fetchPage( pageName, api ) ] ).then( function ( r ) {
            var a = r[ 0 ], wt = ( r[ 1 ] && r[ 1 ].wt ) || '';
            var wasPotd = /\{\{\s*Picture of the day\b/i.test( wt ) ||
                ( a.categories || [] ).some( function ( c ) { return /Picture of the day|Pictures of the day/i.test( c ); } );
            if ( a.isFP && !wasPotd ) li.style.display = '';
        } ).catch( function () {} );

        link.addEventListener( 'click', function ( e ) {
            e.preventDefault();
            var old = link.textContent;
            link.textContent = 'Finding a free date (POTD is booked ~2 yrs ahead)…';
            link.style.pointerEvents = 'none';
            findNextFreePotdDate( api ).then( function ( dateStr ) {
                link.textContent = old; link.style.pointerEvents = '';
                if ( !dateStr ) { alert( 'No free Picture of the Day slot found in the next ~3 years, the schedule is completely full that far out. Try again later.' ); return; }
                showPotdDialog( pageName, user, dateStr, api );
            } ).catch( function () {
                link.textContent = old; link.style.pointerEvents = '';
                alert( 'Failed to scan POTD dates (network or API error).' );
            } );
        } );
    }

    // ─── Category page: per-FP action links (POTD + en.wiki FP) ─────────────
    function setupCategoryActions( api ) {
        var user = mw.config.get( 'wgUserName' );
        if ( !user ) return;
        var boxes = document.querySelectorAll( '#mw-category-media li.gallerybox, .mw-category-generated li.gallerybox, ul.gallery li.gallerybox' );
        if ( !boxes.length ) return;
        var byTitle = {}, titles = [];
        Array.prototype.forEach.call( boxes, function ( box ) {
            var a = box.querySelector( 'a.mw-file-description, a[href*="/wiki/File:"]' );
            if ( !a ) return;
            var title = '';
            try { title = decodeURIComponent( ( a.getAttribute( 'href' ) || '' ).replace( /^.*\/wiki\//, '' ) ).replace( /_/g, ' ' ); } catch ( e ) {}
            if ( !/^File:/.test( title ) ) return;
            if ( !byTitle[ title ] ) { byTitle[ title ] = box; titles.push( title ); }
        } );
        if ( !titles.length ) return;

        // Identify which gallery files are Commons FPs (extmetadata Assessments).
        var fpFiles = [];
        var chain = Promise.resolve();
        for ( var i = 0; i < titles.length; i += 50 ) {
            ( function ( batch ) {
                chain = chain.then( function () {
                    return api.get( { action: 'query', prop: 'imageinfo', iiprop: 'extmetadata', iiextmetadatafilter: 'Assessments', titles: batch.join( '|' ), format: 'json', formatversion: 2 } ).then( function ( r ) {
                        ( ( r.query && r.query.pages ) || [] ).forEach( function ( p ) {
                            var ii = p.imageinfo && p.imageinfo[ 0 ];
                            var ass = ii && ii.extmetadata && ii.extmetadata.Assessments && ii.extmetadata.Assessments.value;
                            if ( ass && /featured/i.test( ass ) ) fpFiles.push( p.title );
                        } );
                    }, function () {} );
                } );
            } )( titles.slice( i, i + 50 ) );
        }
        chain.then( function () {
            if ( !fpFiles.length ) return;
            // For each FP gather, in parallel: (a) which en.wikipedia articles use
            // it (ns 0), and (b) its file wikitext, so we can hide the en-FP link
            // when it is ALREADY a Featured Picture on en.wp, and hide POTD when it
            // was already Picture of the Day.
            var usage = {}, already = {}; // already[title] = { enfp:Bool, potd:Bool }
            var uchain = Promise.resolve();
            for ( var j = 0; j < fpFiles.length; j += 50 ) {
                ( function ( batch ) {
                    uchain = uchain.then( function () {
                        return api.get( { action: 'query', prop: 'globalusage', titles: batch.join( '|' ), gulimit: 'max', guprop: 'namespace', format: 'json', formatversion: 2 } ).then( function ( r ) {
                            ( ( r.query && r.query.pages ) || [] ).forEach( function ( p ) {
                                var arts = ( ( p.globalusage ) || [] ).filter( function ( u ) { return u.wiki === 'en.wikipedia.org' && String( u.ns ) === '0'; } ).map( function ( u ) { return String( u.title ).replace( /_/g, ' ' ); } );
                                arts = arts.filter( function ( v, k ) { return arts.indexOf( v ) === k; } );
                                if ( arts.length ) usage[ p.title ] = arts;
                            } );
                        }, function () {} );
                    } );
                } )( fpFiles.slice( j, j + 50 ) );
            }
            var wchain = Promise.resolve();
            for ( var w = 0; w < fpFiles.length; w += 50 ) {
                ( function ( batch ) {
                    wchain = wchain.then( function () {
                        return api.get( { action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', titles: batch.join( '|' ), format: 'json', formatversion: 2 } ).then( function ( r ) {
                            ( ( r.query && r.query.pages ) || [] ).forEach( function ( p ) {
                                var rev = p.revisions && p.revisions[ 0 ];
                                var wt = ( rev && rev.slots && rev.slots.main && rev.slots.main.content ) || '';
                                already[ p.title ] = { enfp: isAlreadyEnFp( wt ), potd: isAlreadyPotd( wt ) };
                            } );
                        }, function () {} );
                    } );
                } )( fpFiles.slice( w, w + 50 ) );
            }
            Promise.all( [ uchain, wchain ] ).then( function () {
                fpFiles.forEach( function ( title ) {
                    var box = byTitle[ title ];
                    if ( !box || box._cnDone ) return;
                    box._cnDone = true;
                    var flag = already[ title ] || {};
                    var bar = document.createElement( 'div' );
                    bar.className = 'cn-cat-actions';
                    if ( !flag.potd ) {
                        var potd = document.createElement( 'a' );
                        potd.href = '#'; potd.className = 'cn-cat-link'; potd.textContent = 'πŸ—“ POTD';
                        potd.title = 'Schedule this FP as Picture of the Day (next free date)';
                        potd.addEventListener( 'click', function ( e ) { e.preventDefault(); runPotdForFile( title, user, api, potd ); } );
                        bar.appendChild( potd );
                    }
                    var arts = usage[ title ];
                    if ( arts && arts.length && !flag.enfp ) {
                        var enfp = document.createElement( 'a' );
                        enfp.href = '#'; enfp.className = 'cn-cat-link cn-cat-enfp'; enfp.textContent = '⭐ en-FP';
                        enfp.title = 'Nominate as Featured Picture on English Wikipedia, used in: ' + arts.join( ', ' );
                        enfp.addEventListener( 'click', function ( e ) { e.preventDefault(); showEnFpDialog( title, user, arts, api ); } );
                        bar.appendChild( enfp );
                    } else if ( !flag.enfp && ( !arts || !arts.length ) ) {
                        // Not used on en.wp yet β†’ help find articles so it can qualify.
                        var find = document.createElement( 'a' );
                        find.href = '#'; find.className = 'cn-cat-link cn-cat-find'; find.textContent = 'πŸ”Ž Articles';
                        find.title = 'Find en.wikipedia articles where this FP could be placed (so it can later become an en-FP)';
                        find.addEventListener( 'click', function ( e ) { e.preventDefault(); showSuggestArticlesDialog( title, api ); } );
                        bar.appendChild( find );
                    }
                    // When it is already featured on en.wp / already POTD, show a
                    // quiet static badge instead of an actionable link.
                    if ( flag.enfp || flag.potd ) {
                        var done = [];
                        if ( flag.enfp ) done.push( 'β˜… en-FP' );
                        if ( flag.potd ) done.push( 'πŸ—“ POTD' );
                        var badge = document.createElement( 'span' );
                        badge.className = 'cn-cat-flag';
                        badge.textContent = done.join( ' Β· ' );
                        badge.title = 'Already ' + [ flag.enfp ? 'a Featured Picture on English Wikipedia' : '', flag.potd ? 'a Picture of the Day' : '' ].filter( Boolean ).join( ' and ' ) + 'nothing to do.';
                        bar.appendChild( badge );
                    }
                    if ( bar.childNodes.length ) box.appendChild( bar );
                } );
            } );
        } );
    }

    // Reusable POTD launcher (used by the File-page link and the category links).
    function runPotdForFile( filePageName, user, api, linkEl ) {
        var old = linkEl.textContent; linkEl.textContent = '…'; linkEl.style.pointerEvents = 'none';
        fetchPage( filePageName, api ).then( function ( d ) {
            var wt = ( d && d.wt ) || '';
            if ( /\{\{\s*Picture of the day\b/i.test( wt ) ) {
                alert( 'This file is already scheduled as (or was) Picture of the Day.' );
                linkEl.textContent = old; linkEl.style.pointerEvents = ''; return;
            }
            return findNextFreePotdDate( api ).then( function ( dateStr ) {
                linkEl.textContent = old; linkEl.style.pointerEvents = '';
                if ( !dateStr ) { alert( 'No free Picture of the Day slot found in the next ~3 years.' ); return; }
                showPotdDialog( filePageName, user, dateStr, api );
            } );
        } ).catch( function () { linkEl.textContent = old; linkEl.style.pointerEvents = ''; alert( 'Failed (network/API error).' ); } );
    }

    // ─── Wikidata image (P18), propagate a FP to its depicted item ──────────
    // FPCBot tags the file and galleries but never pushes the picture to the
    // depicted Wikidata item, whose P18 feeds infoboxes across ALL Wikipedias.
    // Offered on FP files that have a "depicts" (P180) statement.
    function fetchDepicts( pageid, api ) {
        return api.get( { action: 'wbgetclaims', entity: 'M' + pageid, property: 'P180', format: 'json', formatversion: 2 } ).then( function ( r ) {
            var cl = ( r.claims && r.claims.P180 ) || [];
            cl.sort( function ( a, b ) { return ( b.rank === 'preferred' ? 1 : 0 ) - ( a.rank === 'preferred' ? 1 : 0 ); } );
            return cl.map( function ( c ) { return c.mainsnak && c.mainsnak.datavalue && c.mainsnak.datavalue.value && c.mainsnak.datavalue.value.id; } ).filter( Boolean );
        }, function () { return []; } );
    }

    function setupWikidataImageLink( api ) {
        var pageName = mw.config.get( 'wgPageName' ) || '';
        var user = mw.config.get( 'wgUserName' );
        var pageid = mw.config.get( 'wgArticleId' );
        if ( !user || !/^File:/.test( pageName ) || !pageid ) return;
        var link = addFileActionLink( 'pt-wd-image', 'πŸ–Ό Set as Wikidata image',
            'Use this featured picture as the image (P18) of its depicted Wikidata item' );
        if ( !link ) return;
        var li = link.closest( 'li' ) || link;
        li.style.display = 'none'; // only FPs that depict something qualify
        Promise.all( [ checkFileAssessments( pageName, api ), fetchDepicts( pageid, api ) ] ).then( function ( r ) {
            if ( r[ 0 ].isFP && r[ 1 ].length ) li.style.display = '';
        } ).catch( function () {} );
        link.addEventListener( 'click', function ( e ) { e.preventDefault(); openWikidataImageDialog( pageName, pageid, api ); } );
    }

    function openWikidataImageDialog( pageName, pageid, api ) {
        var filename = pageName.replace( /^File:/, '' ).replace( /_/g, ' ' );
        var m = buildModal( 'Set as Wikidata image, <i>' + escapeAttr( filename ) + '</i>' );
        m.body.innerHTML = '<section class="fpc-nominate-section"><div class="cn-quota">Loading the depicted Wikidata items…</div></section>';
        if ( m.submitBtn ) m.submitBtn.style.display = 'none';
        var wd = ( typeof mw.ForeignApi === 'function' ) ? new mw.ForeignApi( 'https://www.wikidata.org/w/api.php' ) : null;
        fetchDepicts( pageid, api ).then( function ( qids ) {
            if ( !qids.length ) { m.body.innerHTML = '<section class="fpc-nominate-section"><div class="cn-quota">No β€œdepicts” (P180) statements on this file, add some first, then this can link them.</div></section>'; return; }
            if ( !wd ) { m.body.innerHTML = '<section class="fpc-nominate-section"><div class="cn-quota">Cross-wiki API unavailable.</div></section>'; return; }
            var lang = mw.config.get( 'wgUserLanguage' ) || 'en';
            wd.get( { action: 'wbgetentities', ids: qids.join( '|' ), props: 'labels|claims', languages: lang + '|en', format: 'json', formatversion: 2 } ).then( function ( r ) {
                var ents = r.entities || {};
                var rows = qids.map( function ( q ) {
                    var e = ents[ q ] || {};
                    var lab = ( e.labels && ( e.labels[ lang ] || e.labels.en ) && ( e.labels[ lang ] || e.labels.en ).value ) || q;
                    return { q: q, label: lab, hasImage: !!( e.claims && e.claims.P18 && e.claims.P18.length ) };
                } );
                m.body.innerHTML = '<section class="fpc-nominate-section"><div class="cn-hint">Pick the item this picture best represents. Only items <b>without</b> an image can be set, a featured picture is a strong choice for an empty infobox image across every Wikipedia.</div><div class="cn-wd-rows"></div></section>';
                var box = m.body.querySelector( '.cn-wd-rows' );
                rows.forEach( function ( row ) {
                    var div = document.createElement( 'div' ); div.className = 'cn-wd-row';
                    var a = document.createElement( 'a' ); a.href = 'https://www.wikidata.org/wiki/' + row.q; a.target = '_blank'; a.rel = 'noopener'; a.textContent = row.label + ' (' + row.q + ')';
                    div.appendChild( a );
                    if ( row.hasImage ) { var s = document.createElement( 'span' ); s.className = 'cn-wd-has'; s.textContent = 'already has an image'; div.appendChild( s ); }
                    else {
                        var b = document.createElement( 'button' ); b.type = 'button'; b.className = 'cn-wd-set'; b.textContent = 'Set this FP as its image';
                        b.addEventListener( 'click', function () { setWikidataImage( wd, row.q, filename, b, m ); } );
                        div.appendChild( document.createTextNode( ' ' ) ); div.appendChild( b );
                    }
                    box.appendChild( div );
                } );
            }, function () { m.body.innerHTML = '<section class="fpc-nominate-section"><div class="cn-quota">Could not read Wikidata.</div></section>'; } );
        } );
    }

    function setWikidataImage( wd, qid, filename, btn, m ) {
        if ( !confirm( 'Set [[' + filename + ']] as the image (P18) of ' + qid + ' on Wikidata?\n\nThis updates infoboxes that use this item across all Wikipedias. Only do it if the picture clearly represents the subject. (You must be logged in to Wikidata.)' ) ) return;
        btn.disabled = true; btn.textContent = 'Saving…';
        wd.postWithToken( 'csrf', {
            action: 'wbcreateclaim', entity: qid, property: 'P18', snaktype: 'value',
            value: JSON.stringify( filename ),
            summary: 'Set image to a Commons featured picture, via [[:c:User:Wilfredor/commons-nominator.js]]',
            format: 'json', formatversion: 2
        } ).done( function ( res ) {
            if ( res && res.success ) {
                var ok = document.createElement( 'span' ); ok.className = 'cn-wd-done'; ok.textContent = ' βœ“ set';
                if ( btn.parentNode ) btn.parentNode.replaceChild( ok, btn );
                if ( m.status ) m.status.textContent = qid + ' now uses this image.';
            } else { btn.disabled = false; btn.textContent = 'Set this FP as its image'; alert( 'Failed to set the image.' ); }
        } ).fail( function ( code ) {
            btn.disabled = false; btn.textContent = 'Set this FP as its image';
            alert( 'Failed' + ( code ? ' (' + code + ')' : '' ) + 'are you logged in to Wikidata?' );
        } );
    }

    // ─── English Wikipedia Featured Picture nomination (cross-wiki) ──────────
    var EN_FPC = 'Wikipedia:Featured picture candidates';
    var EN_MONTHS = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ];
    // Common paths under [[Wikipedia:Featured pictures/…]] for the category picker.
    var EN_FP_CATS = [
        'Animals/Birds', 'Animals/Insects', 'Animals/Mammals', 'Animals/Marine life',
        'Animals/Reptiles and amphibians', 'Animals/Others',
        'Artwork/Culture, entertainment, and lifestyle', 'Artwork/Paintings', 'Artwork/Sculptures', 'Artwork/Others',
        'Culture, entertainment, and lifestyle/Currency', 'Culture, entertainment, and lifestyle/Others',
        'Diagrams, drawings, and maps/Maps', 'Diagrams, drawings, and maps/Others',
        'Engineering and technology/Machinery', 'Engineering and technology/Others',
        'History/Events', 'History/Others',
        'Natural phenomena/Weather', 'Natural phenomena/Others',
        'People/Others', 'People/Portraits',
        'Places/Architecture', 'Places/Interiors', 'Places/Natural',
        'Plants/Flowers', 'Plants/Fungi', 'Plants/Others',
        'Sciences/Astronomy', 'Sciences/Biology', 'Sciences/Geology', 'Sciences/Medicine',
        'Space/Others',
        'Vehicles/Air', 'Vehicles/Land', 'Vehicles/Water'
    ];

    // Read the first \u007b{Assessments|…}} block's parameter list (inner text), or ''.
    function assessmentsBody( wt ) {
        var m = /\{\{\s*Assessments\b([^}]*)\}\}/i.exec( wt || '' );
        return m ? m[ 1 ] : '';
    }
    // Is this file ALREADY a Featured Picture on English Wikipedia?
    // Recorded on Commons via \u007b{Assessments|…|enwiki=1|…}} (or enwiki=<subpage>).
    function isAlreadyEnFp( wt ) {
        if ( !wt ) return false;
        if ( /\|\s*enwiki\s*=\s*(?!0\b|no\b|false\b)[^|}\s][^|}]*/i.test( assessmentsBody( wt ) ) ) return true;
        // Legacy standalone \u007b{Featured picture|wikipedia=en…}} tag.
        if ( /\{\{\s*Featured[ _]picture\b[^}]*\|\s*wikipedia\s*=\s*[^|}]*\ben\b/i.test( wt ) ) return true;
        return false;
    }
    // Was this file ALREADY Picture of the Day? (scheduled or historic)
    function isAlreadyPotd( wt ) {
        if ( !wt ) return false;
        if ( /\{\{\s*Picture of the day\b/i.test( wt ) ) return true;
        if ( /\|\s*potd\s*=\s*(?!0\b|no\b|false\b)[^|}\s][^|}]*/i.test( assessmentsBody( wt ) ) ) return true;
        return false;
    }

    function showEnFpDialog( filePageName, user, articles, api ) {
        var name = filePageName.replace( /^File:/, '' ).replace( /_/g, ' ' );
        var bare = name.replace( /\.[^.]+$/, '' );
        var m = buildModal( 'English Wikipedia FP, <i>' + escapeAttr( name ) + '</i>' );
        var catOptions = EN_FP_CATS.map( function ( c ) { return '<option value="' + escapeAttr( c ) + '"></option>'; } ).join( '' );
        m.body.innerHTML =
            '<section class="fpc-nominate-section"><div class="cn-quota cn-quota-warn">⚠ This creates a nomination on <b>English Wikipedia</b> (cross-wiki), you must be logged in there. Nothing is saved until you confirm. Voting runs 9 days.</div></section>' +
            '<div class="cn-enfp-grid">' +
            '  <div class="cn-enfp-thumb"><img alt="" /><div class="cn-enfp-thumb-cap"></div></div>' +
            '  <div class="cn-enfp-fields">' +
            '    <section class="fpc-nominate-section"><div class="fpc-nominate-section-title">Caption</div><textarea class="cn-enfp-caption" rows="2"></textarea></section>' +
            '    <section class="fpc-nominate-section"><div class="fpc-nominate-section-title">Reason / encyclopedic value <span class="fpc-nominate-section-hint">(required)</span></div><textarea class="cn-enfp-reason" rows="3" placeholder="Why it is FP-worthy AND the encyclopedic value it adds to the article(s)."></textarea></section>' +
            '    <section class="fpc-nominate-section"><div class="fpc-nominate-section-title">Articles it appears in</div><textarea class="cn-enfp-articles" rows="2"></textarea></section>' +
            '    <section class="fpc-nominate-section"><div class="fpc-nominate-section-title">FP category <span class="fpc-nominate-section-hint">(required)</span></div>' +
            '      <input class="cn-enfp-cat" type="text" list="cn-enfp-catlist" autocomplete="off" placeholder="e.g. Animals/Mammals">' +
            '      <datalist id="cn-enfp-catlist">' + catOptions + '</datalist>' +
            '      <div class="cn-hint">Path under <a href="https://en.wikipedia.org/wiki/Wikipedia:Featured_pictures" target="_blank" rel="noopener">Wikipedia:Featured pictures/</a>, start typing to pick one (e.g. <code>Places/Architecture</code>).</div></section>' +
            '    <section class="fpc-nominate-section"><div class="fpc-nominate-section-title">Creator</div><input class="cn-enfp-creator" type="text" placeholder="User:Name or a name"></section>' +
            '  </div>' +
            '</div>' +
            '<section class="fpc-nominate-section"><div class="fpc-nominate-section-title">Exact wikitext to be posted</div><pre class="cn-enfp-preview" aria-live="polite"></pre></section>';
        var capI = m.body.querySelector( '.cn-enfp-caption' ), reaI = m.body.querySelector( '.cn-enfp-reason' ),
            artI = m.body.querySelector( '.cn-enfp-articles' ), catI = m.body.querySelector( '.cn-enfp-cat' ), creI = m.body.querySelector( '.cn-enfp-creator' ),
            preI = m.body.querySelector( '.cn-enfp-preview' ),
            thumbImg = m.body.querySelector( '.cn-enfp-thumb img' ), thumbCap = m.body.querySelector( '.cn-enfp-thumb-cap' );
        artI.value = ( articles || [] ).map( function ( a ) { return '[[' + a + ']]'; } ).join( ' ' );
        capI.value = "'''Original''' – " + bare;
        // Load a thumbnail + prefill caption fragment / creator from Commons metadata.
        api.get( { action: 'query', prop: 'imageinfo', iiprop: 'extmetadata|user|url', iiurlwidth: 320, titles: filePageName, format: 'json', formatversion: 2 } ).then( function ( r ) {
            var ii = ( ( ( r.query || {} ).pages || [] )[ 0 ] || {} ).imageinfo;
            ii = ii && ii[ 0 ];
            if ( !ii ) return;
            if ( ii.thumburl ) { thumbImg.src = ii.thumburl; }
            var em = ii.extmetadata || {};
            var desc = em.ImageDescription && em.ImageDescription.value;
            if ( desc ) { var tmp = document.createElement( 'div' ); tmp.innerHTML = desc; capI.value = "'''Original''' – " + ( tmp.textContent || '' ).replace( /\s+/g, ' ' ).trim().slice( 0, 200 ); }
            var artist = em.Artist && em.Artist.value;
            if ( artist ) { var t2 = document.createElement( 'div' ); t2.innerHTML = artist; creI.value = ( t2.textContent || '' ).replace( /\s+/g, ' ' ).trim().slice( 0, 60 ); }
            else if ( ii.user ) creI.value = 'User:' + ii.user;
            if ( thumbCap ) thumbCap.textContent = bare;
            refreshPreview();
        } ).catch( function () {} );

        // Build the exact nomination wikitext from the current field values.
        function buildNom() {
            var end = Math.floor( Date.now() / 1000 ) + 9 * 24 * 3600;
            var d = new Date( end * 1000 );
            var ends = d.getUTCDate() + ' ' + EN_MONTHS[ d.getUTCMonth() ] + ' ' + d.getUTCFullYear();
            var endsT = ( '0' + d.getUTCHours() ).slice( -2 ) + ':' + ( '0' + d.getUTCMinutes() ).slice( -2 ) + ':' + ( '0' + d.getUTCSeconds() ).slice( -2 );
            var creator = creI.value.trim() || ( 'User:' + user );
            var crLink = /^User:/i.test( creator ) ? '[[' + creator + '|' + creator.replace( /^User:/i, '' ) + ']]' : creator;
            var now = new Date();
            var monthCat = MONTHS[ now.getUTCMonth() ] + ' ' + now.getUTCFullYear();
            return '===[[' + EN_FPC + '/' + name + '|' + bare + ']]===\n' +
                '\u007b{FPCnom/VotingEnds|' + end + '}}<small>Voting period ends on <b>' + ends + ' </b> at <b>' + endsT + ' (UTC)</b></small>\n' +
                '[\u005bFile:' + name + '|thumb|right|900x260px|' + ( capI.value.trim() || ( "'''Original''' – " + bare ) ) + ']]\n' +
                ';Reason:' + ( reaI.value.trim() || '…' ) + '\n' +
                ';Articles in which this image appears:' + ( artI.value.trim() || '' ) + '\n' +
                ';FP category for this image:[[Wikipedia:Featured pictures/' + ( catI.value.trim() || '…' ) + ']]\n' +
                ';Creator:' + crLink + '\n\n' +
                "* '''Support as nominator''' – \u007e\u007e\u007e\u007e\n" +
                // The float-clearing footer: without \u007b{clear}} the right-floated
                // image overflows into the NEXT nomination on the FPC page. The
                // marker + <noinclude> categories mirror the standard FPC layout.
                '<!-- additional votes go above this line  -->\n' +
                '\u007b{clear}}\n' +
                '<noinclude>[\u005bCategory:Featured picture nominations]] [\u005bCategory:Featured picture nominations/' + monthCat + ']]</noinclude>\n';
        }
        function refreshPreview() { if ( preI ) preI.textContent = buildNom(); }
        [ capI, reaI, artI, catI, creI ].forEach( function ( el ) { el.addEventListener( 'input', refreshPreview ); } );
        refreshPreview();

        m.submitBtn.textContent = 'Post to en.wiki';
        m.submitBtn.addEventListener( 'click', function () {
            var reason = reaI.value.trim(), cat = catI.value.trim();
            if ( !reason ) { m.status.innerHTML = '<span style="color:#b91c1c">A reason / EV is required.</span>'; reaI.focus(); return; }
            if ( !cat ) { m.status.innerHTML = '<span style="color:#b91c1c">The FP category is required.</span>'; catI.focus(); return; }
            // The live preview above already shows the EXACT wikitext, so the
            // "Post to en.wiki" click is itself the confirmation, no native
            // confirm() popup. Post, then close the dialog and notify success.
            var nomText = buildNom();
            m.submitBtn.disabled = true; m.cancelBtn.disabled = true;
            m.status.textContent = 'Posting to English Wikipedia…';
            var url = 'https://en.wikipedia.org/wiki/' + encodeURIComponent( ( EN_FPC + '/' + name ).replace( / /g, '_' ) );
            submitEnwikiFp( name, nomText ).then( function () {
                m.close();
                var link = document.createElement( 'a' );
                link.href = url; link.target = '_blank'; link.rel = 'noopener';
                link.textContent = bare;
                var msg = document.createElement( 'span' );
                msg.appendChild( document.createTextNode( '⭐ Nominated on English Wikipedia: ' ) );
                msg.appendChild( link );
                mw.notify( msg, { type: 'success', autoHide: false, title: 'Featured picture candidate' } );
            } ).catch( function ( err ) {
                m.status.innerHTML = '<span style="color:#b91c1c"><b>Error:</b> ' + escapeAttr( err && err.message ? err.message : ( err && err.error ? err.error.code + ': ' + err.error.info : String( err ) ) ) + '</span>';
                m.submitBtn.disabled = false; m.cancelBtn.disabled = false;
            } );
        } );
    }

    function submitEnwikiFp( name, nomText ) {
        if ( typeof mw.ForeignApi !== 'function' ) return Promise.reject( new Error( 'mw.ForeignApi unavailable.' ) );
        var enApi = new mw.ForeignApi( 'https://en.wikipedia.org/w/api.php' );
        var sub = EN_FPC + '/' + name;
        // 1) create the nomination subpage (createonly, never overwrite).
        return enApi.postWithToken( 'csrf', {
            action: 'edit', title: sub, text: nomText,
            summary: 'Nominating for [[WP:FPC|featured picture]] via [[:c:User:Wilfredor/commons-nominator.js]]',
            createonly: 1, assert: 'user', formatversion: 2
        } ).then( function () {
            // 2) transclude it at the top of the Current-nominations group.
            return enApi.get( { action: 'query', prop: 'revisions', rvprop: 'content|timestamp', rvslots: 'main', titles: EN_FPC, curtimestamp: 1, format: 'json', formatversion: 2 } ).then( function ( r ) {
                var p = r && r.query && r.query.pages && r.query.pages[ 0 ];
                var rev = p && p.revisions && p.revisions[ 0 ];
                var wt = rev && rev.slots && rev.slots.main && rev.slots.main.content;
                if ( !wt ) throw new Error( 'could not read ' + EN_FPC );
                var tpl = '\u007b{' + sub + '}}\n';
                if ( wt.indexOf( tpl.trim() ) >= 0 ) return null; // already transcluded
                var anchorRe = /Place new nominations at the TOP of the group\s*-->/i;
                var mm = anchorRe.exec( wt );
                var newText;
                if ( mm ) { var at = mm.index + mm[ 0 ].length; newText = wt.slice( 0, at ) + '\n' + tpl + wt.slice( at ); }
                else { var fi = wt.search( /\{\{Wikipedia:Featured picture candidates\// ); if ( fi < 0 ) throw new Error( 'could not locate the nominations list' ); newText = wt.slice( 0, fi ) + tpl + wt.slice( fi ); }
                return enApi.postWithToken( 'csrf', {
                    action: 'edit', title: EN_FPC, text: newText,
                    summary: 'Adding [[' + sub + ']] to the featured picture candidates',
                    basetimestamp: rev.timestamp, starttimestamp: r.curtimestamp, nocreate: 1, assert: 'user', formatversion: 2
                } );
            } );
        } );
    }

    // ─── Discover en.wp articles where a FP could be placed ─────────────────
    // For a FP not yet used on English Wikipedia, list candidate articles so the
    // user can add the image and later nominate it as an en.wp Featured Picture.
    // Candidates come from two complementary sources:
    //   β€’ the file's Wikidata "depicts" (P180) items β†’ their en.wp sitelinks
    //     (the most on-topic, an article ABOUT what the picture shows), and
    //   β€’ a full-text search on en.wp built from the filename + description.
    // Articles whose infobox still has no image are flagged as the best targets.
    var EN_STOPWORDS = /^(the|and|for|with|from|near|into|onto|over|under|this|that|these|those|its|his|her|their|los|las|del|una|uno|por|para|con|und|der|die|das|von|dem|des|sur|dans|avec|une)$/i;
    // Distinct significant words (β‰₯3 chars, non-stopword), in order of appearance.
    function queryTerms( bare, desc ) {
        var seen = {}, words = [];
        ( bare + ' ' + ( desc || '' ) ).replace( /\([^)]*\)/g, ' ' )
            .split( /[^A-Za-zΓ€-ΓΏ]+/ ).forEach( function ( w ) {
                if ( w.length < 3 || EN_STOPWORDS.test( w ) ) return;
                var k = w.toLowerCase();
                if ( seen[ k ] ) return; seen[ k ] = 1; words.push( w );
            } );
        return words;
    }
    function buildSearchQuery( bare, desc ) { return queryTerms( bare, desc ).slice( 0, 8 ).join( ' ' ); }
    // Relevance gate: does an article title share β‰₯1 significant word with the
    // file's own terms? Keeps text-search noise (loosely-matching articles) out.
    function titleShares( title, termSet ) {
        var words = String( title ).split( /[^A-Za-zΓ€-ΓΏ]+/ );
        for ( var i = 0; i < words.length; i++ ) {
            var w = words[ i ];
            if ( w.length >= 3 && !EN_STOPWORDS.test( w ) && termSet[ w.toLowerCase() ] ) return true;
        }
        return false;
    }

    function flagEmptyInfobox( cands, enApi ) {
        if ( !enApi || !cands.length ) return Promise.resolve();
        var titles = cands.map( function ( c ) { return c.title; } );
        var chain = Promise.resolve();
        for ( var i = 0; i < titles.length; i += 40 ) {
            ( function ( batch ) {
                chain = chain.then( function () {
                    return enApi.get( { action: 'query', prop: 'revisions', rvprop: 'content', rvslots: 'main', titles: batch.join( '|' ), format: 'json', formatversion: 2 } ).then( function ( r ) {
                        var wtByTitle = {};
                        ( ( r.query && r.query.pages ) || [] ).forEach( function ( p ) {
                            var rev = p.revisions && p.revisions[ 0 ];
                            wtByTitle[ String( p.title ).replace( /_/g, ' ' ) ] = ( rev && rev.slots && rev.slots.main && rev.slots.main.content ) || '';
                        } );
                        cands.forEach( function ( c ) {
                            if ( !( c.title in wtByTitle ) ) return;
                            var wt = wtByTitle[ c.title ];
                            c.hasInfobox = /\{\{\s*Infobox/i.test( wt );
                            var mimg = /\|\s*image(?:_?file|_?name)?\s*=\s*([^\n|]*)/i.exec( wt );
                            c.emptyImage = c.hasInfobox && !( mimg && mimg[ 1 ].trim() );
                        } );
                    }, function () {} );
                } );
            } )( titles.slice( i, i + 40 ) );
        }
        return chain.then( function () {
            cands.sort( function ( a, b ) { return ( b.emptyImage ? 1 : 0 ) - ( a.emptyImage ? 1 : 0 ); } );
        } );
    }

    function suggestionRow( c ) {
        var row = document.createElement( 'div' ); row.className = 'cn-sugg-row' + ( c.emptyImage ? ' cn-sugg-good' : '' );
        var enc = encodeURIComponent( c.title.replace( / /g, '_' ) );
        var head = document.createElement( 'div' ); head.className = 'cn-sugg-head';
        var a = document.createElement( 'a' ); a.href = 'https://en.wikipedia.org/wiki/' + enc; a.target = '_blank'; a.rel = 'noopener'; a.textContent = c.title; a.className = 'cn-sugg-title';
        head.appendChild( a );
        var src = document.createElement( 'span' ); src.className = 'cn-sugg-src cn-sugg-src-' + c.src; src.textContent = c.why; head.appendChild( src );
        if ( c.emptyImage ) { var g = document.createElement( 'span' ); g.className = 'cn-sugg-badge'; g.textContent = 'empty infobox image'; head.appendChild( g ); }
        else if ( c.illustrated ) { var g2 = document.createElement( 'span' ); g2.className = 'cn-sugg-badge cn-sugg-badge-muted'; g2.textContent = 'already illustrated'; head.appendChild( g2 ); }
        row.appendChild( head );
        if ( c.note ) { var n = document.createElement( 'div' ); n.className = 'cn-sugg-note'; n.textContent = c.note; row.appendChild( n ); }
        var edit = document.createElement( 'a' ); edit.href = 'https://en.wikipedia.org/w/index.php?title=' + enc + '&action=edit'; edit.target = '_blank'; edit.rel = 'noopener'; edit.textContent = 'Open editor β†’'; edit.className = 'cn-sugg-link';
        row.appendChild( edit );
        return row;
    }

    function renderSuggestions( name, cands, quota, listEl ) {
        // An article "already illustrated" = has an infobox that already carries an
        // image. Those are poor targets, so hide them behind a toggle by default;
        // articles with an empty infobox image or no infobox stay visible.
        cands.forEach( function ( c ) { c.illustrated = ( c.hasInfobox === true && !c.emptyImage ); } );
        var primary = cands.filter( function ( c ) { return !c.illustrated; } );
        var hidden = cands.filter( function ( c ) { return c.illustrated; } );
        var good = primary.filter( function ( c ) { return c.emptyImage; } ).length;

        quota.innerHTML = 'Found <b>' + primary.length + '</b> candidate article' + ( primary.length === 1 ? '' : 's' ) +
            ( good ? '<b>' + good + '</b> with an empty infobox image (best targets).' : '.' ) +
            ' Add the picture to a fitting one, then reload this page, the ⭐ en-FP action appears once it is used in an article.';

        // Ready-to-paste file snippet.
        var snip = document.createElement( 'div' ); snip.className = 'cn-sugg-snip';
        var inp = document.createElement( 'input' ); inp.type = 'text'; inp.readOnly = true;
        inp.value = '[\u005bFile:' + name + '|thumb|' + name.replace( /\.[^.]+$/, '' ) + ']]';
        var copy = document.createElement( 'button' ); copy.type = 'button'; copy.className = 'cn-sugg-copy'; copy.textContent = 'Copy';
        copy.addEventListener( 'click', function () {
            inp.select();
            var done = function () { copy.textContent = 'Copied βœ“'; setTimeout( function () { copy.textContent = 'Copy'; }, 1500 ); };
            if ( navigator.clipboard && navigator.clipboard.writeText ) { navigator.clipboard.writeText( inp.value ).then( done, function () { try { document.execCommand( 'copy' ); done(); } catch ( e ) {} } ); }
            else { try { document.execCommand( 'copy' ); done(); } catch ( e ) {} }
        } );
        snip.appendChild( inp ); snip.appendChild( copy );
        listEl.appendChild( snip );

        if ( !primary.length ) {
            var none = document.createElement( 'div' ); none.className = 'cn-sugg-note';
            none.textContent = hidden.length ? 'Every candidate already has an infobox image, see the ' + hidden.length + ' below.' : 'No article candidates without an image were found.';
            listEl.appendChild( none );
        }
        primary.forEach( function ( c ) { listEl.appendChild( suggestionRow( c ) ); } );

        if ( hidden.length ) {
            var wrap = document.createElement( 'div' ); wrap.className = 'cn-sugg-hidden'; wrap.style.display = 'none';
            hidden.forEach( function ( c ) { wrap.appendChild( suggestionRow( c ) ); } );
            var toggle = document.createElement( 'a' ); toggle.href = '#'; toggle.className = 'cn-sugg-toggle';
            toggle.textContent = 'Show ' + hidden.length + ' already-illustrated article' + ( hidden.length === 1 ? '' : 's' ) + ' β–Ύ';
            toggle.addEventListener( 'click', function ( e ) {
                e.preventDefault();
                var open = wrap.style.display === 'none';
                wrap.style.display = open ? '' : 'none';
                toggle.textContent = ( open ? 'Hide ' : 'Show ' ) + hidden.length + ' already-illustrated article' + ( hidden.length === 1 ? '' : 's' ) + ( open ? ' β–΄' : ' β–Ύ' );
            } );
            listEl.appendChild( toggle );
            listEl.appendChild( wrap );
        }
    }

    function showSuggestArticlesDialog( fileTitle, api ) {
        var name = fileTitle.replace( /^File:/, '' ).replace( /_/g, ' ' );
        var bare = name.replace( /\.[^.]+$/, '' );
        var m = buildModal( 'Find en.wp articles, <i>' + escapeAttr( name ) + '</i>' );
        if ( m.submitBtn ) m.submitBtn.style.display = 'none';
        m.body.innerHTML = '<section class="fpc-nominate-section"><div class="cn-quota">Looking for English Wikipedia articles this picture could illustrate…</div></section><div class="cn-sugg-list"></div>';
        var listEl = m.body.querySelector( '.cn-sugg-list' );
        var quota = m.body.querySelector( '.cn-quota' );
        var enApi = ( typeof mw.ForeignApi === 'function' ) ? new mw.ForeignApi( 'https://en.wikipedia.org/w/api.php' ) : null;
        var wd = ( typeof mw.ForeignApi === 'function' ) ? new mw.ForeignApi( 'https://www.wikidata.org/w/api.php' ) : null;

        api.get( { action: 'query', titles: fileTitle, prop: 'info|imageinfo', iiprop: 'extmetadata', iiextmetadatafilter: 'ImageDescription', format: 'json', formatversion: 2 } ).then( function ( r ) {
            var p = ( ( r.query || {} ).pages || [] )[ 0 ] || {};
            var pageid = p.pageid;
            var ii = p.imageinfo && p.imageinfo[ 0 ];
            var descHtml = ii && ii.extmetadata && ii.extmetadata.ImageDescription && ii.extmetadata.ImageDescription.value;
            var descTxt = '';
            if ( descHtml ) { var t = document.createElement( 'div' ); t.innerHTML = descHtml; descTxt = ( t.textContent || '' ).replace( /\s+/g, ' ' ).trim(); }

            var depictsP = ( pageid && wd ) ? fetchDepicts( pageid, api ).then( function ( qids ) {
                if ( !qids.length ) return [];
                return wd.get( { action: 'wbgetentities', ids: qids.join( '|' ), props: 'labels|descriptions|sitelinks', languages: 'en', sitefilter: 'enwiki', format: 'json', formatversion: 2 } ).then( function ( rr ) {
                    var ents = rr.entities || {};
                    return qids.map( function ( q ) {
                        var e = ents[ q ] || {};
                        var sl = e.sitelinks && e.sitelinks.enwiki && e.sitelinks.enwiki.title;
                        if ( !sl ) return null;
                        var lab = ( e.labels && e.labels.en && e.labels.en.value ) || sl;
                        var edesc = ( e.descriptions && e.descriptions.en && e.descriptions.en.value ) || '';
                        return { title: sl, why: 'depicts β€œ' + lab + '”', note: edesc, src: 'depicts' };
                    } ).filter( Boolean );
                }, function () { return []; } );
            } ) : Promise.resolve( [] );

            var terms = queryTerms( bare, descTxt );
            var termSet = {}; terms.forEach( function ( w ) { termSet[ w.toLowerCase() ] = 1; } );
            var q = terms.slice( 0, 8 ).join( ' ' );
            var searchP = ( enApi && q ) ? enApi.get( { action: 'query', list: 'search', srsearch: q, srnamespace: 0, srlimit: 12, srprop: 'snippet', format: 'json', formatversion: 2 } ).then( function ( rr ) {
                return ( ( rr.query && rr.query.search ) || [] )
                    // Relevance gate: keep only results whose title shares a word
                    // with the file, drops loosely-matching full-text noise.
                    .filter( function ( s ) { return titleShares( s.title, termSet ); } )
                    .map( function ( s ) {
                        var snip = ( s.snippet || '' ).replace( /<[^>]+>/g, '' ).replace( /&[a-z]+;/g, ' ' ).replace( /\s+/g, ' ' ).trim();
                        return { title: s.title, why: 'text match', note: snip, src: 'search' };
                    } );
            }, function () { return []; } ) : Promise.resolve( [] );

            return Promise.all( [ depictsP, searchP ] ).then( function ( res ) {
                var byTitle = {}, cands = [];
                res[ 0 ].concat( res[ 1 ] ).forEach( function ( c ) {
                    var k = c.title.replace( /_/g, ' ' );
                    if ( byTitle[ k ] ) { if ( c.src === 'depicts' ) byTitle[ k ].why = c.why + ' Β· ' + byTitle[ k ].why; return; }
                    c.title = k; byTitle[ k ] = c; cands.push( c );
                } );
                if ( !cands.length ) { quota.textContent = 'No obvious article candidates found. Try adding a β€œdepicts” statement on the file, then reopen this.'; return; }
                return flagEmptyInfobox( cands, enApi ).then( function () { renderSuggestions( name, cands, quota, listEl ); } );
            } );
        } ).catch( function () { quota.textContent = 'Could not gather candidates (network/API error).'; } );
    }

    function showPotdDialog( filePageName, user, dateStr, api ) {
        var fileTitle = filePageName.replace( /^File:/, '' ).replace( /_/g, ' ' );
        var m = buildModal( 'Picture of the Day, <i>' + escapeAttr( fileTitle ) + '</i>' );
        m.body.innerHTML =
            '<section class="fpc-nominate-section">' +
            '  <div class="cn-quota">Next free date: <b>' + dateStr + '</b> (UTC)' +
            ( function () {
                var days = Math.round( ( Date.parse( dateStr + 'T00:00:00Z' ) - Date.now() ) / 86400000 );
                var months = Math.round( days / 30.4 );
                return days > 60 ? 'about <b>' + months + ' months</b> from now (POTD is scheduled far ahead; this is the earliest empty slot).' : 'the slot is currently empty.';
            }() ) +
            '</div>' +
            '</section>' +
            '<section class="fpc-nominate-section">' +
            '  <div class="fpc-nominate-section-title">English description</div>' +
            '  <textarea class="cn-potd-desc"></textarea>' +
            '  <div class="cn-hint">Goes into \u007b{Potd description}}; also adds \u007b{Picture of the day}} to the file page. ' +
            '    <b>Tip:</b> like other POTD entries, link the key terms, <code>\u007b{w|Subject}}</code> or <code>[[:en:Place|Place]]</code>, and italicise titles/species (<code>\'\'…\'\'</code>); a plain line works but reads poorer. ' +
            '    Self-service per <a href="https://commons.wikimedia.org/wiki/Commons:Picture_of_the_day/Instructions" target="_blank">the POTD instructions</a>.</div>' +
            '</section>';
        var descInput = m.body.querySelector( '.cn-potd-desc' );
        descInput.placeholder = 'Loading the file\'s English description…';
        descInput.focus();
        fetchEnglishDescription( filePageName, api ).then( function ( d ) {
            descInput.placeholder = 'One or two sentences shown under the image.';
            if ( d && !descInput.value.trim() ) descInput.value = truncateDescription( d, 300 );
        } );

        m.submitBtn.addEventListener( 'click', function () {
            var desc = sanitizeWikiField( descInput.value );
            if ( !desc.ok ) {
                m.status.innerHTML = '<span style="color:#b91c1c">The description has ' + escapeAttr( desc.error ) + 'please fix it.</span>';
                descInput.focus(); return;
            }
            if ( !desc.value ) {
                m.status.innerHTML = '<span style="color:#b91c1c">Please add a short description.</span>';
                descInput.focus(); return;
            }
            m.submitBtn.disabled = true; m.cancelBtn.disabled = true;
            m.status.textContent = 'Scheduling Picture of the Day for ' + dateStr + '…';
            schedulePotd( filePageName, dateStr, desc.value, api ).then( function () {
                m.status.textContent = 'Done! Opening the POTD template…';
                setTimeout( function () {
                    window.location.href = '/wiki/' + encodeURIComponent( ( POTD_PREFIX + dateStr ).replace( / /g, '_' ) );
                }, 1000 );
            } ).catch( function ( err ) {
                var msg = err && err.message ? err.message : ( err && err.error ? err.error.code + ': ' + err.error.info : String( err ) );
                m.status.innerHTML = '<span style="color:#b91c1c"><b>Error:</b> ' + escapeAttr( msg ) + '</span>';
                m.submitBtn.disabled = false; m.cancelBtn.disabled = false;
            } );
        } );
    }

    function schedulePotd( filePageName, dateStr, description, api ) {
        var name = filePageName.replace( /^File:/, '' ).replace( /_/g, ' ' );
        var parts = dateStr.split( '-' ), Y = parts[ 0 ], M = parts[ 1 ], D = parts[ 2 ];
        var fnPage = POTD_PREFIX + dateStr;
        var descPage = POTD_PREFIX + dateStr + ' (en)';
        // 1) filename, re-check the slot is STILL free, then set it (never overwrite).
        return fetchPage( fnPage, api ).then( function ( fp ) {
            if ( !fp.missing && !potdFilenameEmpty( fp.wt ) ) {
                // Our OWN name already in the slot means an earlier attempt got
                // past this step and failed at the caption or the file page.
                // Skip ahead instead of refusing. Otherwise the retry reports
                // "filled by someone else", us, and the day stays scheduled
                // with an image and no caption, unfixable from the UI.
                if ( potdFilenameOf( fp.wt ) === name ) return null;
                throw new Error( 'The slot for ' + dateStr + ' was just filled by someone else, reopen to pick the next free date.' );
            }
            var fnText = '\u007b{Potd filename|1=' + name +
                '\n<!--DON\'T EDIT BELOW THIS LINE. IT FILLS OUT THE REST FOR YOU. \n-->|2=' + Y + '|3=' + M + '|4=' + D + '}}';
            return api.postWithToken( 'csrf', {
                action: 'edit', title: fnPage, text: fnText,
                summary: 'Set Picture of the Day (' + dateStr + '): ' + name + ' via [[User:Wilfredor/commons-nominator.js|commons-nominator]]',
                basetimestamp: fp.basetimestamp || undefined, baserevid: fp.baserevid || undefined,
                assert: 'user', formatversion: 2
            } );
        } ).then( function () {
            var dText = '\u007b{Potd description|1=' + description + '|2=en|3=' + Y + '|4=' + M + '|5=' + D + '}}';
            // Read first so the write can be pinned: this was the only one of
            // the three POTD edits with no conflict protection, so it would
            // blindly overwrite a caption written between our steps.
            return fetchPage( descPage, api ).then( function ( dp ) {
                return api.postWithToken( 'csrf', {
                    action: 'edit', title: descPage, text: dText,
                    summary: 'POTD description (' + dateStr + ') via [[User:Wilfredor/commons-nominator.js|commons-nominator]]',
                    basetimestamp: dp.basetimestamp || undefined, baserevid: dp.baserevid || undefined,
                    assert: 'user', formatversion: 2
                } );
            } );
        } ).then( function () {
            return fetchPage( filePageName, api ).then( function ( fpage ) {
                if ( /\{\{\s*Picture of the day\b/i.test( fpage.wt ) ) return null;
                return api.postWithToken( 'csrf', {
                    action: 'edit', title: filePageName,
                    prependtext: '\u007b{Picture of the day|year=' + Y + '|month=' + M + '|day=' + D + '}}\n',
                    summary: 'Scheduled as Picture of the Day ' + dateStr + ' via [[User:Wilfredor/commons-nominator.js|commons-nominator]]',
                    basetimestamp: fpage.basetimestamp || undefined, baserevid: fpage.baserevid || undefined,
                    assert: 'user', formatversion: 2
                } );
            } );
        } );
    }

    // Splice the new line into the candidate list. Re-reads fresh wikitext,
    // re-checks the dup + 5/day guards against it (closing the precheck→submit
    // race), pins baserevid/basetimestamp, and inserts at the TOP of today's
    // gallery (creating today's date section if this is the day's first nom).
    function insertQicLine( filePageName, line, user, api ) {
        return fetchPage( QIC_LIST, api ).then( function ( data ) {
            var wt = data.wt;
            if ( qicAlreadyNominated( wt, filePageName ) ) {
                throw new Error( 'This file already has an open QIC nomination.' );
            }
            var cnt = countQicNomsToday( wt, user );
            if ( cnt >= 5 ) {
                throw new Error( 'QIC daily limit reached (5/day). You already have ' + cnt +
                    ' under today\'s date section.' );
            }
            var heading = '== ' + qicTodayHeadingText() + ' ==';
            var newText;
            var mh = qicTodayHeadingRe().exec( wt );
            if ( mh ) {
                var secStart = mh.index + mh[ 0 ].length;
                var nextSec = wt.slice( secStart ).search( /\n==[^=]/ );
                var secEnd = nextSec >= 0 ? secStart + nextSec : wt.length;
                // Tolerate gallery attributes (e.g. "\u003cgallery mode=packed>").
                var galRe = /\u003cgallery\b[^>]*>/g;
                galRe.lastIndex = secStart;
                var gm = galRe.exec( wt );
                if ( !gm || gm.index >= secEnd ) {
                    throw new Error( "Could not find today's \u003cgallery> block." );
                }
                var insertAt = gm.index + gm[ 0 ].length;
                newText = wt.slice( 0, insertAt ) + '\n' + line + wt.slice( insertAt );
            } else {
                var block = '\n\n' + heading + '\n\u003cgallery>\n' + line + '\n\u003c/gallery>\n';
                var anchor = 'new nominations -->';
                var aIdx = wt.indexOf( anchor );
                if ( aIdx >= 0 ) {
                    var after = aIdx + anchor.length;
                    newText = wt.slice( 0, after ) + block + wt.slice( after );
                } else {
                    var nomH = wt.search( /^=\s*Nominations\s*=/m );
                    if ( nomH < 0 ) throw new Error( 'Could not locate the QIC nominations section.' );
                    var nl = wt.indexOf( '\n', nomH );
                    newText = wt.slice( 0, nl + 1 ) + block + wt.slice( nl + 1 );
                }
            }
            return api.postWithToken( 'csrf', {
                action: 'edit',
                title: QIC_LIST,
                text: newText,
                summary: 'Nominating [[:' + filePageName.replace( /_/g, ' ' ) +
                    ']] for QI (via [[User:Wilfredor/commons-nominator.js|commons-nominator]])',
                baserevid: data.baserevid,
                basetimestamp: data.basetimestamp,
                nocreate: 1,
                assert: 'user',
                formatversion: 2
            } );
        } );
    }

    // ─── Valued Image Candidates (VIC) ──────────────────────────────────────
    // VIC nomination = create a per-file subpage with \u007b{VIC}} (scope required),
    // then append the bare filename to the \u007b{VICs}} list. No daily/open cap.
    // Per-scope uniqueness ("most valued image of its kind") is a reviewer
    // judgement; we surface it as a reminder, not a hard block.

    function vicAlreadyNominated( wt, filePageName ) {
        var name = filePageName.replace( /^File:/, '' );
        var esc = name.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ).replace( /[ _]/g, '[ _]' );
        return new RegExp( '\\n\\s*\\|\\s*' + esc + '\\s*(?:\\n|$)', 'i' ).test( '\n' + wt );
    }

    function setupVicNominateLink( api ) {
        var pageName = mw.config.get( 'wgPageName' ) || '';
        var user = mw.config.get( 'wgUserName' );
        if ( !user || !/^File:/.test( pageName ) ) return;
        var link = addFileActionLink( 'pt-vic-nominate', 'β“‹ Nominate for VI',
            'Nominate this file for Valued Image' );
        if ( !link ) return;
        var assessState = checkFileAssessments( pageName, api );
        assessState.then( function ( a ) {
            if ( a.isVI ) {
                // VI is per-scope, so being a VI does NOT block a nomination for a
                // DIFFERENT scope, the link stays active; the tooltip just informs.
                ( link.closest( 'li' ) || link ).classList.add( 'cn-already' );
                link.title = 'Already a Valued Image, VI is per-scope, so you can still nominate it for a different scope.';
            }
        } ).catch( function () {} );

        link.addEventListener( 'click', function ( e ) {
            e.preventDefault();
            var old = link.textContent;
            link.textContent = 'Checking…';
            link.style.pointerEvents = 'none';
            Promise.all( [ assessState, fetchPage( VIC_LIST, api ) ] ).then( function ( r ) {
                link.textContent = old;
                link.style.pointerEvents = '';
                var assess = r[ 0 ];
                var list = r[ 1 ];
                if ( vicAlreadyNominated( list.wt, pageName ) ) {
                    alert( 'This file already has an open VIC nomination on the candidate list.' );
                    return;
                }
                // Already a VI? No popup, the tooltip already explains it is
                // per-scope; proceed straight to the dialog (new scope).
                showVicDialog( pageName, user, api );
            } ).catch( function () {
                link.textContent = old;
                link.style.pointerEvents = '';
                alert( 'Failed to check VIC preconditions (network or API error). Please retry in a moment.' );
            } );
        } );
    }

    function showVicDialog( filePageName, user, api ) {
        var fileTitle = filePageName.replace( /^File:/, '' ).replace( /_/g, ' ' );
        var m = buildModal( 'Nominate <i>' + escapeAttr( fileTitle ) + '</i> for Valued Image' );
        m.body.innerHTML =
            '<section class="fpc-nominate-section">' +
            '  <div class="fpc-nominate-section-title">Scope <span class="fpc-nominate-section-hint">(required)</span></div>' +
            '  <select class="cn-vic-cat"><option value="">pick one of this file\'s categories to fill the scope</option></select>' +
            '  <textarea class="cn-vic-scope" placeholder="What this is the most valuable illustration OF, e.g. [[:Category:Foo|A short caption describing the subject]]"></textarea>' +
            '  <div class="cn-hint">The scope defines what the image illustrates. Only ONE image can be the ' +
            '    Valued Image of a given scope, so keep it specific. Please enable the ' +
            '    <a href="https://commons.wikimedia.org/wiki/Special:Preferences#mw-prefsection-gadgets" target="_blank">FastCCI gadget</a> ' +
            '    and check no similar-scope VI already exists.</div>' +
            '</section>' +
            '<section class="fpc-nominate-section">' +
            '  <div class="fpc-nominate-section-title">Orientation</div>' +
            '  <select class="cn-vic-orientation">' +
            '    <option value="landscape">landscape</option>' +
            '    <option value="portrait">portrait</option>' +
            '    <option value="panorama">panorama</option>' +
            '  </select>' +
            '</section>' +
            '<section class="fpc-nominate-section">' +
            '  <div class="fpc-nominate-section-title">Reason <span class="fpc-nominate-section-hint">(optional)</span></div>' +
            '  <textarea class="cn-vic-reason" placeholder="Why this image best illustrates the scope (optional)."></textarea>' +
            '</section>' +
            '<section class="fpc-nominate-section">' +
            '  <div class="fpc-nominate-section-title">Used in <span class="fpc-nominate-section-hint">(optional)</span></div>' +
            '  <textarea class="cn-vic-usedin" placeholder="Articles/pages that use this image, one per line."></textarea>' +
            '  <div class="cn-hint">If the image is already used in articles, listing them here <b>reinforces the value argument</b>. Auto-filled from the file\'s real usages, edit as you like. Not required to nominate.</div>' +
            '</section>';
        var scopeInput = m.body.querySelector( '.cn-vic-scope' );
        var reasonInput = m.body.querySelector( '.cn-vic-reason' );
        var orientInput = m.body.querySelector( '.cn-vic-orientation' );
        var catSelect = m.body.querySelector( '.cn-vic-cat' );
        var usedinInput = m.body.querySelector( '.cn-vic-usedin' );

        // Auto-fill "used in" from the file's real article usages (GlobalUsage).
        usedinInput.placeholder = 'Looking up where this image is used…';
        fetchVicUsedIn( filePageName, api ).then( function ( list ) {
            usedinInput.placeholder = 'Articles/pages that use this image, one per line.';
            if ( list && !usedinInput.value.trim() ) usedinInput.value = list;
        } );

        // Prefill the reason with the standard VIC wording (still editable).
        // Verified against promoted nominations: the reason is plain
        // "best in scope", the category lives in the separate scope= field and
        // is NOT repeated here.
        reasonInput.value = 'best in scope';

        // Populate the scope dropdown from the file's categories; picking one
        // writes a ready-made [[:Category:X|X]] scope that stays editable.
        fetchFileCategories( filePageName, api ).then( function ( cats ) {
            cats.forEach( function ( c ) {
                var o = document.createElement( 'option' );
                o.value = c; o.textContent = c;
                catSelect.appendChild( o );
            } );
        } );
        catSelect.addEventListener( 'change', function () {
            var c = catSelect.value;
            if ( c ) {
                scopeInput.value = '[[:Category:' + c + '|' + c + ']]';
                // Standard VIC reason is plain "best in scope" (the scope itself
                // is the scope= field, not repeated in the reason), keep it
                // unless the nominator typed something custom.
                if ( !reasonInput.value.trim() || /^best in scope\b/i.test( reasonInput.value.trim() ) ) {
                    reasonInput.value = 'best in scope';
                }
            }
            scopeInput.focus();
        } );
        scopeInput.focus();

        m.submitBtn.addEventListener( 'click', function () {
            var scope = sanitizeWikiField( scopeInput.value );
            if ( !scope.ok ) {
                m.status.innerHTML = '<span style="color:#b91c1c">The scope has ' +
                    escapeAttr( scope.error ) + 'please fix it.</span>';
                scopeInput.focus();
                return;
            }
            if ( !scope.value ) {
                m.status.innerHTML = '<span style="color:#b91c1c">A scope is required for a VIC nomination.</span>';
                scopeInput.focus();
                return;
            }
            var reason = sanitizeWikiField( reasonInput.value );
            if ( !reason.ok ) {
                m.status.innerHTML = '<span style="color:#b91c1c">The reason has ' +
                    escapeAttr( reason.error ) + 'please fix it.</span>';
                reasonInput.focus();
                return;
            }
            var usedin = sanitizeUsedIn( usedinInput.value );
            if ( !usedin.ok ) {
                m.status.innerHTML = '<span style="color:#b91c1c">The "used in" list has ' +
                    escapeAttr( usedin.error ) + 'please fix it.</span>';
                usedinInput.focus();
                return;
            }
            m.submitBtn.disabled = true;
            m.cancelBtn.disabled = true;
            m.status.textContent = 'Creating the VIC nomination subpage…';
            submitVic( filePageName, scope.value, reason.value, orientInput.value, usedin.value, api )
                .then( function ( subpageTitle ) {
                    m.status.textContent = 'Done! Opening your nomination…';
                    setTimeout( function () {
                        window.location.href = '/wiki/' + encodeURIComponent( subpageTitle );
                    }, 1000 );
                } )
                .catch( function ( err ) {
                    var msg = err && err.error ? ( err.error.code + ': ' + err.error.info )
                        : ( err && err.message ? err.message : String( err ) );
                    m.status.innerHTML = '<span style="color:#b91c1c"><b>Error:</b> ' + escapeAttr( msg ) + '</span>';
                    m.submitBtn.disabled = false;
                    m.cancelBtn.disabled = false;
                } );
        } );
    }

    // Canonical \u007b{VIC}} subpage. subst:SUBPAGENAME / subst:VI-time / \u007e\u007e\u007e are
    // resolved server-side by PST when this edit saves, which reproduces the
    // exact title encoding the site uses. The <noinclude>/<includeonly> switch
    // makes the page render \u007b{VIC}} standalone and \u007b{VIC-thumb}} when the
    // \u007b{VICs}} list transcludes it.
    // `cleanScope` / `cleanReason` MUST already be sanitised by sanitizeWikiField
    // (balanced braces/brackets, top-level pipes neutralised), the dialog does
    // that and rejects invalid input, so the free text cannot close \u007b{VIC}}
    // early or inject a stray template parameter.
    // Light multi-line sanitiser for the "used in" list. Balance/neutralise each
    // line via sanitizeWikiField but KEEP the line breaks (it is a bullet list).
    function sanitizeUsedIn( s ) {
        var lines = ( s || '' ).split( /\r?\n/ );
        var out = [];
        for ( var i = 0; i < lines.length; i++ ) {
            if ( !lines[ i ].trim() ) continue;
            var r = sanitizeWikiField( lines[ i ] );
            if ( !r.ok ) return r;
            out.push( r.value );
        }
        return { ok: true, value: out.join( '\n' ) };
    }

    // Build the "used in" bullet list from the file's GlobalUsage on content
    // (article, ns 0) pages of Wikipedias, as [[:lang:Title]] interwiki links.
    function fetchVicUsedIn( filePageName, api ) {
        return api.get( {
            action: 'query', prop: 'globalusage', titles: filePageName,
            gulimit: 'max', guprop: 'namespace', format: 'json', formatversion: 2
        } ).then( function ( r ) {
            var p = r && r.query && r.query.pages && r.query.pages[ 0 ];
            var gu = ( p && p.globalusage ) || [];
            var out = [], seen = {};
            gu.forEach( function ( u ) {
                if ( String( u.ns ) !== '0' ) return; // article (content) namespace only
                var mw_ = ( u.wiki || '' ).match( /^([a-z-]+)\.wikipedia\.org$/ ); // Wikipedias
                if ( !mw_ ) return;
                var link = '* [[:' + mw_[ 1 ] + ':' + String( u.title ).replace( /_/g, ' ' ) + ']]';
                if ( !seen[ link ] ) { seen[ link ] = true; out.push( link ); }
            } );
            return out.slice( 0, 20 ).join( '\n' );
        }, function () { return ''; } );
    }

    function buildVicSubpage( cleanScope, cleanReason, orientation, cleanUsedIn ) {
        var orient = /^(landscape|portrait|panorama)$/.test( orientation ) ? orientation : 'landscape';
        var sc = ( cleanScope || '' ).trim();
        var rs = ( cleanReason || '' ).trim();
        var L = [];
        L.push( '<noinclude>=== \u007b{subst:SUBPAGENAME}} ===' );
        L.push( '' );
        L.push( '\u007b{VIC</noinclude><includeonly>\u007b{VIC-thumb</includeonly>' );
        L.push( '|subpage=\u007b{subst:SUBPAGENAME}}' );
        L.push( '|image=\u007b{subst:SUBPAGENAME}}' );
        L.push( '|date=\u007b{subst:VI-time}}' );
        L.push( '|nominator=\u007e\u007e\u007e' );
        L.push( '|scope=' + sc );
        L.push( '|orientation=' + orient + ' <!-- change to portrait or panorama if relevant -->' );
        var ui = ( cleanUsedIn || '' ).trim();
        L.push( ui ? '|usedin=\n' + ui : '|usedin=<!-- List of links to usages on Wikimedia project content pages (optional) -->' );
        L.push( '|status=nominated <!-- Change to supported, opposed or discussed as appropriate when adding reviews -->' );
        L.push( '|reason=' + ( rs ? rs : '<!-- Reason for nominating (optional) -->' ) );
        L.push( '|review=<!-- Itemized list of review comments. -->' );
        L.push( '}}' );
        return L.join( '\n' );
    }

    // Create the subpage FIRST (createonly), then append to the \u007b{VICs}} list.
    // If the list append fails the subpage is left as an orphan, the error is
    // surfaced verbatim so the user can retry that step rather than re-creating.
    function submitVic( filePageName, scope, reason, orientation, usedin, api ) {
        var name = filePageName.replace( /^File:/, '' ).replace( /_/g, ' ' );
        var subpageTitle = VIC_SUBPAGE_PREFIX + name;
        var content = buildVicSubpage( scope, reason, orientation, usedin );
        return api.postWithToken( 'csrf', {
            action: 'edit',
            title: subpageTitle,
            text: content,
            summary: 'Nominating for VI (via [[User:Wilfredor/commons-nominator.js|commons-nominator]])',
            createonly: 1,
            assert: 'user',
            formatversion: 2
        } ).catch( function ( err ) {
            // Make a retry after a partial run resumable. If step 1 already
            // succeeded on an earlier click (orphan subpage) the API now rejects
            // with "articleexists". Treat that as "subpage already there" and
            // fall through to the list append, appendVicListEntry's own
            // idempotency guard completes the nomination instead of dead-ending.
            if ( err && err.error && err.error.code === 'articleexists' ) return null;
            throw err;
        } ).then( function () {
            return appendVicListEntry( name, api ).then( function () { return subpageTitle; } );
        } );
    }

    // Append "|<filename>" as the last entry inside \u007b{VICs}}, immediately above
    // the "ADD NEW NOMINATIONS ABOVE THIS LINE" anchor. Pins baserevid; idem-
    // potent if the entry is already present.
    function appendVicListEntry( name, api ) {
        return fetchPage( VIC_LIST, api ).then( function ( data ) {
            var wt = data.wt;
            if ( vicAlreadyNominated( wt, 'File:' + name ) ) {
                return { skipped: true };
            }
            var anchor = '<!--ADD NEW NOMINATIONS ABOVE THIS LINE--->';
            var idx = wt.indexOf( anchor );
            if ( idx < 0 ) {
                throw new Error( 'Subpage was created, but the VIC list insertion anchor was not found,' +
                    'please add "|' + name + '" to the \u007b{VICs}} list manually.' );
            }
            var newText = wt.slice( 0, idx ) + '|' + name + '\n' + wt.slice( idx );
            return api.postWithToken( 'csrf', {
                action: 'edit',
                title: VIC_LIST,
                text: newText,
                summary: 'Adding [\u005b:File:' + name + ']] to the VIC candidate list ' +
                    '(via [[User:Wilfredor/commons-nominator.js|commons-nominator]])',
                baserevid: data.baserevid,
                basetimestamp: data.basetimestamp,
                nocreate: 1,
                assert: 'user',
                formatversion: 2
            } );
        } );
    }

    function injectStyles() {
        var css = [
            // Portlet link color hints depending on existing assessments.
            // QI = green, VI = blue, QI+VI = purple (synergy hint), already FP = greyed.
            '#pt-fpc-nominate.fpc-nominate-qi a { background:#d1fae5 !important; color:#047857 !important; font-weight:bold; }',
            '#pt-fpc-nominate.fpc-nominate-vi a { background:#dbeafe !important; color:#1d4ed8 !important; font-weight:bold; }',
            '#pt-fpc-nominate.fpc-nominate-qi-vi a { background:linear-gradient(135deg,#d1fae5,#dbeafe) !important; color:#5b21b6 !important; font-weight:bold; }',
            '#pt-fpc-nominate.fpc-nominate-already-fp a { background:#f3f4f6 !important; color:#9ca3af !important; text-decoration:line-through; cursor:not-allowed; }',
            // Nominate-for-FP dialog (modal triggered by portlet link).
            '.fpc-archiver-nominate-overlay { position:fixed; inset:0; background:rgba(15,23,42,0.5); backdrop-filter:blur(2px); -webkit-backdrop-filter:blur(2px); z-index:10000; display:flex; align-items:center; justify-content:center; padding:24px; }',
            // Dialog is resizable via the bottom-right corner. min sizes keep
            // it usable, max sizes keep it within viewport.
            '.fpc-archiver-nominate-dialog { background:#fff; color:#0f172a; border-radius:8px; width:min(1180px, calc(100vw - 48px)); height:min(720px, calc(100vh - 48px)); min-width:640px; min-height:480px; max-width:calc(100vw - 48px); max-height:calc(100vh - 48px); display:flex; flex-direction:column; box-shadow:0 20px 60px rgba(0,0,0,0.35); font-size:14px; line-height:1.4; overflow:hidden; resize:both; }',
            // Header: single compact bar with title + close button.
            '.fpc-archiver-nominate-dialog .fpc-nominate-header { display:flex; align-items:center; gap:12px; padding:12px 18px; background:#f8fafc; border-bottom:1px solid #e2e8f0; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-title { font-size:15px; font-weight:bold; color:#0f172a; flex:1; min-width:0; line-height:1.3; word-break:break-word; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-title i { font-style:normal; color:#1e3a8a; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-close { background:none; border:none; color:#64748b; font-size:22px; line-height:1; padding:2px 8px; cursor:pointer; border-radius:4px; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-close:hover { background:#e2e8f0; color:#0f172a; }',
            // Body: two columns side by side. Each column scrolls independently
            // so a long readiness list doesn\'t push the form off-screen.
            '.fpc-archiver-nominate-dialog .fpc-nominate-body { display:flex; gap:0; flex:1; min-height:0; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-col-form { flex:0 0 44%; min-width:0; padding:14px 20px 18px; overflow-y:auto; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-col-readiness { flex:1; min-width:0; padding:14px 20px 18px; background:#fafbfc; border-left:1px solid #e2e8f0; overflow-y:auto; }',
            '@media (max-width:820px) { .fpc-archiver-nominate-dialog { min-width:unset; width:calc(100vw - 32px); } .fpc-archiver-nominate-dialog .fpc-nominate-body { flex-direction:column; } .fpc-archiver-nominate-dialog .fpc-nominate-col-form, .fpc-archiver-nominate-dialog .fpc-nominate-col-readiness { flex:1 1 auto; } .fpc-archiver-nominate-dialog .fpc-nominate-col-readiness { border-left:none; border-top:1px solid #e2e8f0; } }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-section { margin:0 0 14px; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-section-title { font-size:12.5px; font-weight:bold; color:#475569; text-transform:uppercase; letter-spacing:0.04em; margin-bottom:6px; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-section-hint { font-size:11.5px; color:#94a3b8; font-weight:normal; text-transform:none; letter-spacing:0; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-comment { width:100%; box-sizing:border-box; height:80px; padding:8px 10px; font-family:inherit; font-size:13px; border:1px solid #cbd5e1; border-radius:5px; resize:vertical; line-height:1.4; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-comment:focus { outline:none; border-color:#1d4ed8; box-shadow:0 0 0 2px rgba(29,78,216,0.15); }',
            // Options row (self-support + notify).
            '.fpc-archiver-nominate-dialog .fpc-nominate-options { display:flex; flex-direction:column; gap:6px; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-option { display:flex; align-items:center; gap:8px; font-size:13px; color:#334155; padding:6px 10px; background:#f8fafc; border-radius:5px; cursor:pointer; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-option:hover { background:#f1f5f9; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-option input { margin:0; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-option code { background:#e0e7ff; color:#3730a3; padding:1px 5px; border-radius:3px; font-size:0.9em; }',
            // Footer.
            '.fpc-archiver-nominate-dialog .fpc-nominate-footer { padding:10px 18px; border-top:1px solid #e2e8f0; background:#fff; display:flex; align-items:center; gap:12px; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-actions { display:flex; gap:8px; margin-left:auto; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-hint { color:#0e7490; font-size:12px; margin:6px 0 0; background:#ecfeff; padding:6px 10px; border-radius:4px; border-left:3px solid #06b6d4; }',
            // Live-search autocomplete dropdown under the Gallery input.
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-wrap { position:relative; margin-top:4px; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery { width:100%; padding:6px 8px; box-sizing:border-box; font-family:inherit; font-size:inherit; border:1px solid #d1d5db; border-radius:3px; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery:focus { outline:none; border-color:#1e40af; box-shadow:0 0 0 2px rgba(30,64,175,0.15); }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-suggest { position:absolute; top:100%; left:0; right:0; background:#fff; border:1px solid #d1d5db; border-top:none; border-radius:0 0 3px 3px; max-height:240px; overflow-y:auto; z-index:10001; box-shadow:0 4px 12px rgba(0,0,0,0.12); }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-suggest-item { padding:6px 10px; cursor:pointer; font-size:0.9em; color:#111827; border-bottom:1px solid #f3f4f6; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-suggest-item:last-child { border-bottom:none; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-suggest-item:hover, .fpc-archiver-nominate-dialog .fpc-nominate-gallery-suggest-item.active { background:#eff6ff; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-suggest-item mark { background:#fde68a; color:#92400e; padding:0 1px; border-radius:2px; }',
            // Tree-browser panel under the input.
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree { margin-top:6px; border:1px solid #d1d5db; border-radius:4px; background:#fff; max-height:340px; overflow-y:auto; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-crumbs { padding:6px 10px; background:#f9fafb; border-bottom:1px solid #e5e7eb; font-size:0.88em; color:#374151; position:sticky; top:0; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-crumbs a { color:#1e40af; text-decoration:none; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-crumbs a:hover { text-decoration:underline; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-sep { color:#9ca3af; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-pick-here { display:block; width:calc(100% - 16px); margin:8px; padding:6px 10px; background:#ecfdf5; border:1px solid #6ee7b7; color:#065f46; border-radius:3px; cursor:pointer; text-align:left; font-size:0.88em; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-pick-here:hover { background:#d1fae5; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-body { display:flex; gap:0; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-col { flex:1; min-width:0; padding:4px 0; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-col + .fpc-nominate-gallery-tree-col { border-left:1px solid #e5e7eb; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-colhead { padding:4px 10px; font-size:0.78em; text-transform:uppercase; color:#6b7280; letter-spacing:0.04em; font-weight:bold; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-item { padding:6px 10px; cursor:pointer; font-size:0.9em; color:#111827; line-height:1.3; display:flex; align-items:flex-start; gap:10px; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-icon { width:22px; height:22px; object-fit:contain; flex-shrink:0; margin-top:1px; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-icon-fallback { width:22px; display:inline-block; text-align:center; flex-shrink:0; margin-top:1px; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-text { flex:1; min-width:0; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-name { font-weight:500; color:#111827; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-desc { font-size:11px; color:#94a3b8; line-height:1.3; margin-top:1px; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-desc:empty { display:none; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-item:hover { background:#eff6ff; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-section { color:#5b21b6; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-loading, .fpc-archiver-nominate-dialog .fpc-nominate-gallery-tree-empty { padding:14px; color:#6b7280; font-size:0.88em; text-align:center; }',
            '.fpc-archiver-nominate-dialog button { padding:8px 16px; border:1px solid #cbd5e1; background:#fff; border-radius:5px; cursor:pointer; font-size:13px; font-weight:500; color:#334155; transition:background 0.12s, border-color 0.12s, transform 0.06s; }',
            '.fpc-archiver-nominate-dialog button:hover { background:#f1f5f9; border-color:#94a3b8; }',
            '.fpc-archiver-nominate-dialog button:active { transform:translateY(1px); }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-submit { background:linear-gradient(180deg,#2563eb,#1d4ed8); color:#fff; border-color:#1e3a8a; padding:8px 20px; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-submit:hover { background:linear-gradient(180deg,#1d4ed8,#1e3a8a); border-color:#1e3a8a; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-submit:disabled { background:#94a3b8; cursor:default; border-color:#94a3b8; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-status { font-size:12.5px; color:#475569; flex:1; min-width:0; }',
            // Samples-of-this-category panel (right column, top).
            '.fpc-archiver-nominate-dialog .fpc-nominate-samples-section { margin:0 0 18px; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-samples-body { font-size:12.5px; color:#475569; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-samples-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(110px, 1fr)); gap:6px; margin-top:4px; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-samples-thumb { display:block; aspect-ratio:1/1; background:#e2e8f0; border-radius:4px; overflow:hidden; border:1px solid #e2e8f0; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-samples-thumb img { width:100%; height:100%; object-fit:cover; display:block; transition:transform 0.18s; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-samples-thumb:hover img { transform:scale(1.06); }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-samples-meta { margin-top:8px; font-size:11px; color:#94a3b8; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-samples-meta a { color:#1d4ed8; text-decoration:none; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-samples-empty { font-size:12px; color:#94a3b8; padding:8px 0; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-samples-empty a { color:#1d4ed8; text-decoration:none; }',
            // Pre-submit readiness panel (verdict pill + N key signals).
            '.fpc-archiver-nominate-dialog .fpc-nominate-readiness-section { margin:0; padding:0; padding-top:14px; border-top:1px solid #e2e8f0; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-readiness-pill { display:inline-block; padding:3px 10px; border-radius:12px; font-weight:bold; font-size:0.88em; }',
            '.fpc-archiver-nominate-dialog .fpc-ready-ok { background:#d1fae5; color:#047857; border:1px solid #6ee7b7; }',
            '.fpc-archiver-nominate-dialog .fpc-ready-borderline { background:#fef3c7; color:#92400e; border:1px solid #fbbf24; }',
            '.fpc-archiver-nominate-dialog .fpc-ready-risky { background:#fee2e2; color:#991b1b; border:1px solid #f87171; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-readiness-lines { margin:10px 0 0; padding:0; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-readiness-line { display:flex; gap:10px; padding:10px 12px; background:#fff; border:1px solid #e2e8f0; border-radius:5px; margin-bottom:6px; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-readiness-line-critical { border-color:#fca5a5; background:#fef2f2; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-readiness-badge { width:20px; height:20px; flex-shrink:0; border-radius:50%; display:flex; align-items:center; justify-content:center; font-weight:bold; font-size:12px; line-height:1; }',
            '.fpc-archiver-nominate-dialog .fpc-readiness-badge-warn { background:#fef3c7; color:#92400e; }',
            '.fpc-archiver-nominate-dialog .fpc-readiness-badge-crit { background:#fee2e2; color:#991b1b; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-readiness-line-body { flex:1; min-width:0; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-readiness-line-name { font-weight:bold; font-size:13px; color:#0f172a; margin-bottom:3px; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-readiness-line-note { font-size:12px; line-height:1.45; color:#475569; }',
            '.fpc-archiver-nominate-dialog .fpc-nominate-readiness-more { margin-top:6px; font-size:11.5px; color:#94a3b8; font-style:italic; text-align:center; }',
            // Rule-11 banner shown on FPC subpage creation when user is over limit.
            '.fpc-archiver-2nom-banner { background:#fef3c7; border:2px solid #d97706; color:#7c2d12; padding:14px 18px; margin:12px 0 18px; border-radius:6px; line-height:1.5; }',
            '.fpc-archiver-2nom-banner .fpc-archiver-2nom-head { font-size:1.05em; font-weight:bold; margin-bottom:6px; }',
            '.fpc-archiver-2nom-banner ul { margin:6px 0 8px 24px; }',
            '.fpc-archiver-2nom-banner .fpc-archiver-2nom-override { margin-left:8px; padding:4px 10px; background:#fff; border:1px solid #92400e; color:#7c2d12; border-radius:3px; cursor:pointer; }',
            '.fpc-archiver-2nom-banner .fpc-archiver-2nom-override:disabled { background:#f3f4f6; cursor:default; }',
            // commons-nominator: QIC/VIC portlet hints + compact dialog.
            '#pt-qic-nominate.cn-already a, #pt-vic-nominate.cn-already a, #pt-qic-nominate.cn-notown a { color:#9ca3af !important; }',
            '.cn-disabled a { cursor:default !important; opacity:0.6; text-decoration:none !important; }',
            '.cn-fpc-rename-btn { margin-left:10px; padding:3px 8px; font-size:12px; line-height:1.2; border:1px solid #a2a9b1; border-radius:3px; background:#f8f9fa; color:#202122; cursor:pointer; vertical-align:middle; }',
            '.cn-fpc-rename-btn:hover { background:#fff; border-color:#72777d; }',
            '.cn-rename-form { display:grid; gap:10px; min-width:min(560px, 82vw); }',
            '.cn-rename-form label { display:grid; gap:4px; font-size:12px; font-weight:700; color:#334155; }',
            '.cn-rename-form input { box-sizing:border-box; width:100%; padding:7px 9px; border:1px solid #cbd5e1; border-radius:5px; font-size:13px; font-weight:400; color:#0f172a; background:#fff; }',
            '.cn-rename-form input[readonly] { background:#f8fafc; color:#475569; }',
            '.cn-rename-plan { margin-top:2px; padding:10px 12px; border:1px solid #e2e8f0; border-radius:5px; background:#f8fafc; font-size:12.5px; color:#334155; }',
            '.cn-rename-plan ul { margin:6px 0 0 18px; padding:0; }',
            '.cn-cat-actions { margin:3px 0 2px; text-align:center; line-height:1.4; }',
            '.cn-cat-link { display:inline-block; margin:1px 3px; padding:1px 7px; font-size:11px; font-weight:600; border-radius:9px; background:#1f2937; color:#fff !important; text-decoration:none !important; cursor:pointer; }',
            '.cn-cat-link:hover { background:#374151; }',
            '.cn-cat-link.cn-cat-enfp { background:#b45309; }',
            '.cn-cat-link.cn-cat-enfp:hover { background:#92400e; }',
            '.cn-cat-flag { display:inline-block; margin:1px 3px; padding:1px 7px; font-size:11px; font-weight:600; border-radius:9px; background:#e2e8f0; color:#475569; cursor:default; }',
            '.cn-cat-link.cn-cat-find { background:#0f766e; }',
            '.cn-cat-link.cn-cat-find:hover { background:#115e59; }',
            '.cn-sugg-snip { display:flex; gap:6px; margin:0 0 10px; }',
            '.cn-sugg-snip input { flex:1; min-width:0; padding:6px 8px; font-size:12px; border:1px solid #cbd5e1; border-radius:5px; background:#f8fafc; color:#334155; }',
            '.cn-sugg-copy { padding:6px 12px; border:0; border-radius:5px; background:#1d4ed8; color:#fff; font-weight:600; cursor:pointer; }',
            '.cn-sugg-copy:hover { background:#1e40af; }',
            '.cn-sugg-row { padding:8px 0; border-top:1px solid #eef2f7; }',
            '.cn-sugg-row.cn-sugg-good { background:#f0fdf4; margin:0 -8px; padding:8px; border-top:0; border-radius:6px; }',
            '.cn-sugg-head { display:flex; align-items:center; flex-wrap:wrap; gap:6px; }',
            '.cn-sugg-title { color:#1d4ed8; font-weight:600; font-size:13px; }',
            '.cn-sugg-src { font-size:10.5px; font-weight:600; padding:1px 6px; border-radius:8px; background:#e2e8f0; color:#475569; }',
            '.cn-sugg-src-depicts { background:#ede9fe; color:#6d28d9; }',
            '.cn-sugg-badge { font-size:10.5px; font-weight:700; padding:1px 6px; border-radius:8px; background:#16a34a; color:#fff; }',
            '.cn-sugg-badge-muted { background:#cbd5e1 !important; color:#475569 !important; }',
            '.cn-sugg-note { font-size:12px; color:#64748b; line-height:1.4; margin:3px 0 2px; }',
            '.cn-sugg-link { font-size:12px; color:#0f766e; font-weight:600; }',
            '.cn-sugg-toggle { display:inline-block; margin:10px 0 2px; font-size:12px; font-weight:600; color:#475569; }',
            '.cn-sugg-hidden .cn-sugg-row { opacity:0.72; }',
            '.cn-enfp-cat, .cn-enfp-creator { width:100%; box-sizing:border-box; padding:6px 8px; font-size:13px; border:1px solid #cbd5e1; border-radius:5px; }',
            '.cn-quota.cn-quota-warn { background:#fffbeb; border-color:#fcd34d; color:#78350f; }',
            '.cn-enfp-grid { display:flex; gap:14px; align-items:flex-start; }',
            '.cn-enfp-grid .cn-enfp-fields { flex:1; min-width:0; }',
            '.cn-enfp-thumb { flex:0 0 168px; width:168px; text-align:center; }',
            '.cn-enfp-thumb img { max-width:100%; border-radius:6px; border:1px solid #e2e8f0; background:#f8fafc; display:block; }',
            '.cn-enfp-thumb-cap { margin-top:4px; font-size:11px; color:#64748b; line-height:1.35; word-break:break-word; }',
            '.cn-enfp-preview { margin:0; padding:8px 10px; background:#0f172a; color:#e2e8f0; border-radius:5px; font-size:11.5px; line-height:1.5; white-space:pre-wrap; word-break:break-word; max-height:190px; overflow:auto; }',
            '@media (max-width:520px){ .cn-enfp-grid { display:block; } .cn-enfp-thumb { width:auto; margin-bottom:10px; } }',
            '.cn-wd-row { padding:6px 0; border-top:1px solid #eef2f7; font-size:13px; }',
            '.cn-wd-row:first-child { border-top:0; }',
            '.cn-wd-row a { color:#1d4ed8; }',
            '.cn-wd-has { color:#64748b; font-style:italic; }',
            '.cn-wd-set { margin-left:6px; padding:3px 10px; background:#0d9488; color:#fff; border:0; border-radius:4px; font-weight:600; cursor:pointer; }',
            '.cn-wd-set:hover { background:#0f766e; }',
            '.cn-wd-set:disabled { opacity:0.6; cursor:default; }',
            '.cn-wd-done { color:#0d9488; font-weight:700; }',
            '.fpc-archiver-nominate-dialog.cn-compact-dialog { width:min(560px, calc(100vw - 48px)); height:auto; max-height:min(640px, calc(100vh - 48px)); min-width:unset; min-height:unset; resize:none; }',
            '.fpc-archiver-nominate-dialog.cn-compact-dialog .cn-compact-body { display:block; padding:16px 20px; overflow-y:auto; }',
            '.fpc-archiver-nominate-dialog .cn-compact-body textarea { width:100%; box-sizing:border-box; min-height:64px; padding:8px 10px; font-family:inherit; font-size:13px; border:1px solid #cbd5e1; border-radius:5px; resize:vertical; line-height:1.4; }',
            '.fpc-archiver-nominate-dialog .cn-compact-body textarea:focus, .fpc-archiver-nominate-dialog .cn-compact-body select:focus { outline:none; border-color:#1d4ed8; box-shadow:0 0 0 2px rgba(29,78,216,0.15); }',
            '.fpc-archiver-nominate-dialog .cn-compact-body select { padding:6px 8px; font-family:inherit; font-size:13px; border:1px solid #cbd5e1; border-radius:5px; }',
            '.fpc-archiver-nominate-dialog .cn-hint { color:#475569; font-size:12px; margin-top:6px; line-height:1.45; }',
            '.fpc-archiver-nominate-dialog .cn-hint a { color:#1d4ed8; }',
            '.fpc-archiver-nominate-dialog .cn-quota { font-size:13px; color:#334155; background:#f8fafc; border:1px solid #e2e8f0; border-radius:5px; padding:8px 10px; }',
            ''
        ].join( '\n' );
        var s = document.createElement( 'style' );
        s.textContent = css;
        document.head.appendChild( s );
    }

}() );