BooruKit Deck

Selection toolbar and post-state framework for the BooruKit userscript suite.

이 스크립트는 직접 설치하는 용도가 아닙니다. 다른 스크립트에서 메타 지시문 // @require https://update.sleazyfork.org/scripts/589428/1890614/BooruKit%20Deck.js을(를) 사용하여 포함하는 라이브러리입니다.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         BooruKit Deck
// @namespace    codeberg.org/0xdev/boorukit
// @version      0.1.0
// @description  Selection toolbar and post-state framework for the BooruKit userscript suite.
// @author       0xdev
// @license      LGPL-3.0-or-later
// ==/UserScript==

// THIS FILE IS AUTO-GENERATED — DO NOT MANUALLY EDIT.
// Source: boorukit/boorukit-deck.library.js
(() => {
    'use strict';
    const DEFAULT_THUMB_SELECTOR = 'a[href*="page=post"][href*="s=view"]';
    const GELBOORU_ID_PATTERN = /[?&]id=(\d+)/;
    const SITE_CONFIGS = {
        'rule34.xxx': {
            thumbSelector: DEFAULT_THUMB_SELECTOR,
            galleryRootSelector: '#post-list',
            idPattern: GELBOORU_ID_PATTERN,
            galleryUrlKind: 'query',
        },
        'safebooru.org': {
            thumbSelector: DEFAULT_THUMB_SELECTOR,
            galleryRootSelector: '#post-list',
            idPattern: GELBOORU_ID_PATTERN,
            galleryUrlKind: 'query',
        },
        'gelbooru.com': {
            thumbSelector: DEFAULT_THUMB_SELECTOR,
            galleryRootSelector: '#post-list',
            idPattern: GELBOORU_ID_PATTERN,
            galleryUrlKind: 'query',
        },
        'hypnohub.net': {
            thumbSelector: DEFAULT_THUMB_SELECTOR,
            galleryRootSelector: '#post-list',
            idPattern: GELBOORU_ID_PATTERN,
            galleryUrlKind: 'query',
        },
        'e621.net': {
            thumbSelector: 'article.thumbnail a[href*="/posts/"]',
            galleryRootSelector: '#posts-container',
            idPattern: /\/posts\/(\d+)/,
            galleryUrlKind: 'path',
        },
        'e926.net': {
            thumbSelector: 'article.thumbnail a[href*="/posts/"]',
            galleryRootSelector: '#posts-container',
            idPattern: /\/posts\/(\d+)/,
            galleryUrlKind: 'path',
        },
    };
    const PAGE_CSS = `.boorukit-thumb { position: relative; }
.boorukit-selected { outline: 3px solid #35c46a !important; }
.boorukit-failed { outline-color: #d33 !important; }
.boorukit-selection-armed #thumbPlusPreviewContainer {
  display: none !important;
}`;
    const SHADOW_CSS = `
:host {
  --bk-ink: #151a21;
  --bk-panel: #222a35;
  --bk-panel-raised: #2c3643;
  --bk-text: #f2f6fb;
  --bk-muted: #9eabba;
  --bk-signal: #58b8e8;
  --bk-selected: #45c879;
  --bk-danger: #f06d78;
  position: fixed;
  right: 16px;
  bottom: 16px;
  z-index: 900;
  color: var(--bk-text);
  font-family: system-ui, sans-serif;
}
button, input { font: inherit; }
button {
  min-height: 36px;
  border: 1px solid #465568;
  border-radius: 5px;
  padding: 6px 11px;
  background: var(--bk-panel-raised);
  color: var(--bk-text);
  cursor: pointer;
}
button:hover { border-color: var(--bk-signal); }
button:focus-visible, input:focus-visible {
  outline: 3px solid var(--bk-signal);
  outline-offset: 2px;
}
.launcher {
  border-color: #536579;
  background: var(--bk-panel);
  font-family: "Aptos Narrow", "Arial Narrow", "Segoe UI Variable", sans-serif;
  font-weight: 700;
  letter-spacing: .04em;
}
.instrument {
  box-sizing: border-box;
  display: grid;
  gap: 7px;
  width: max-content;
  max-width: min(760px, calc(100vw - 24px));
  border: 1px solid #435064;
  border-radius: 8px;
  padding: 8px;
  background: var(--bk-panel);
  box-shadow: 0 4px 14px rgb(0 0 0 / 28%);
}
.instrument[hidden], .settings[hidden], .progress[hidden],
.panel-content[hidden] { display: none; }
.shell-row, .feature-row, .progress {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 7px;
}
.feature-row:empty { display: none; }
.tally {
  min-height: 36px;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 0 8px 0 5px;
  border: 1px solid #435064;
  border-radius: 5px;
  background: var(--bk-ink);
  color: var(--bk-muted);
  font-family: ui-monospace, "Cascadia Mono", monospace;
}
.tally strong { color: var(--bk-text); }
.tally-mark {
  display: grid;
  grid-template-columns: repeat(2, 5px);
  grid-template-rows: repeat(2, 5px);
  gap: 2px;
}
.tally-mark > span { background: var(--bk-selected); }
.settings, .panel-content {
  border-top: 1px solid #435064;
  padding-top: 7px;
}
.settings-list { display: grid; gap: 6px; }
.setting {
  display: flex;
  align-items: center;
  gap: 7px;
  min-height: 36px;
  color: var(--bk-text);
}
.setting-note, .status { color: var(--bk-muted); font-size: 12px; }
.status[data-state="unavailable"] { color: var(--bk-danger); }
.progress {
  border-top: 1px solid #435064;
  padding-top: 7px;
}
.progress-label {
  font-family: "Aptos Narrow", "Arial Narrow", "Segoe UI Variable", sans-serif;
  font-weight: 700;
}
.progress-count { font-family: ui-monospace, "Cascadia Mono", monospace; }
@media (max-width: 560px) {
  :host { right: 8px; bottom: 8px; }
  .instrument { max-width: calc(100vw - 16px); }
}
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    scroll-behavior: auto !important;
    transition-duration: 0s !important;
    animation-duration: 0s !important;
  }
}`;
    function isGalleryPage(locationLike, kind) {
        if (kind === 'path') {
            return locationLike.pathname === '/posts'
                || locationLike.pathname === '/posts/';
        }
        const params = new URLSearchParams(locationLike.search);
        return params.get('page') === 'post' && params.get('s') === 'list';
    }
    function postIdFromHref(href, pattern) {
        const match = pattern.exec(href);
        return match ? match[1] : null;
    }
    function md5FromThumbSrc(src) {
        const match = /thumbnail_([0-9a-fA-F]{32})/.exec(src);
        return match ? match[1].toLowerCase() : null;
    }
    function resolveSiteConfig(hostname) {
        const normalized = hostname.toLowerCase().replace(/^www\./, '');
        const known = SITE_CONFIGS[normalized];
        if (known)
            return known;
        if (normalized.endsWith('.booru.org')) {
            return {
                thumbSelector: DEFAULT_THUMB_SELECTOR,
                galleryRootSelector: '#post-list',
                idPattern: GELBOORU_ID_PATTERN,
                galleryUrlKind: 'query',
            };
        }
        return null;
    }
    function scanThumbs(root, config) {
        const thumbs = [];
        const seen = new Set();
        for (const anchor of Array.from(root.querySelectorAll(config.thumbSelector))) {
            const image = anchor.querySelector('img');
            if (!image)
                continue;
            const id = postIdFromHref(anchor.getAttribute('href') || '', config.idPattern);
            if (!id || seen.has(id))
                continue;
            seen.add(id);
            thumbs.push({
                id,
                md5: md5FromThumbSrc(image.getAttribute('src') || '') || '',
                thumbUrl: image.src,
                anchor,
            });
        }
        return thumbs;
    }
    function createSelection(onChange) {
        const ids = new Set();
        return {
            toggle(id) {
                if (ids.has(id))
                    ids.delete(id);
                else
                    ids.add(id);
                onChange();
            },
            has: (id) => ids.has(id),
            ids: () => [...ids],
            clear() {
                ids.clear();
                onChange();
            },
            size: () => ids.size,
        };
    }
    function createThumbClickHandler(thumbs, selection, config, isModeOn) {
        return (event) => {
            if (!isModeOn())
                return;
            if (event.button !== 0
                || event.ctrlKey
                || event.metaKey
                || event.shiftKey
                || event.altKey)
                return;
            const target = event.target;
            const anchor = target && typeof target.closest === 'function'
                ? target.closest('a')
                : null;
            if (!anchor)
                return;
            const id = postIdFromHref(anchor.getAttribute('href') || '', config.idPattern);
            const thumb = id ? thumbs.find((candidate) => candidate.id === id) : null;
            if (!thumb)
                return;
            event.preventDefault();
            event.stopPropagation();
            selection.toggle(thumb.id);
            thumb.anchor.classList.add('boorukit-thumb');
            thumb.anchor.classList.toggle('boorukit-selected', selection.has(thumb.id));
        };
    }
    function applyThumbState(anchor, state, reason) {
        anchor.classList.add('boorukit-thumb');
        if (state === 'failed') {
            anchor.classList.add('boorukit-failed');
            anchor.title = `feature failed: ${reason ?? 'unknown'}`;
            return;
        }
        anchor.classList.remove('boorukit-failed');
        anchor.removeAttribute('title');
    }
    function createDeckController(context, doc = document) {
        const resolvedConfig = resolveSiteConfig(context.page.hostname);
        if (!resolvedConfig)
            throw new Error('Deck does not support this site');
        const config = resolvedConfig;
        const addStyle = globalThis.GM_addStyle;
        addStyle?.(PAGE_CSS);
        const host = doc.createElement('div');
        host.setAttribute('data-boorukit-deck', '');
        const shadowRoot = host.attachShadow({ mode: 'open' });
        const style = doc.createElement('style');
        style.textContent = SHADOW_CSS;
        const launcher = doc.createElement('button');
        launcher.type = 'button';
        launcher.className = 'launcher';
        launcher.textContent = 'BooruKit';
        launcher.setAttribute('aria-label', 'Open BooruKit selection controls');
        const instrument = doc.createElement('section');
        instrument.className = 'instrument';
        instrument.hidden = true;
        instrument.setAttribute('aria-label', 'BooruKit selection controls');
        const shellRow = doc.createElement('div');
        shellRow.className = 'shell-row';
        const tally = doc.createElement('span');
        tally.className = 'tally';
        const tallyMark = doc.createElement('span');
        tallyMark.className = 'tally-mark';
        tallyMark.setAttribute('aria-hidden', 'true');
        for (let index = 0; index < 4; index += 1) {
            tallyMark.appendChild(doc.createElement('span'));
        }
        const count = doc.createElement('strong');
        count.setAttribute('data-selection-count', '');
        count.textContent = '0';
        const countLabel = doc.createElement('span');
        countLabel.textContent = 'selected';
        tally.append(tallyMark, count, countLabel);
        function commandButton(command, label) {
            const button = doc.createElement('button');
            button.type = 'button';
            button.setAttribute('data-command', command);
            button.textContent = label;
            return button;
        }
        const selectPage = commandButton('select-page', 'Select page');
        const clear = commandButton('clear', 'Clear');
        const settingsButton = commandButton('settings', 'Settings');
        const closeButton = commandButton('close', 'Close');
        shellRow.append(tally, selectPage, clear, settingsButton, closeButton);
        const featureRow = doc.createElement('div');
        featureRow.className = 'feature-row';
        const settings = doc.createElement('section');
        settings.className = 'settings';
        settings.hidden = true;
        settings.setAttribute('aria-label', 'BooruKit settings');
        const settingsList = doc.createElement('div');
        settingsList.className = 'settings-list';
        settings.appendChild(settingsList);
        const panelContent = doc.createElement('section');
        panelContent.className = 'panel-content';
        panelContent.setAttribute('data-panel-content', '');
        panelContent.hidden = true;
        const progress = doc.createElement('section');
        progress.className = 'progress';
        progress.setAttribute('data-progress', '');
        progress.setAttribute('aria-live', 'polite');
        progress.hidden = true;
        const progressLabel = doc.createElement('span');
        progressLabel.className = 'progress-label';
        progressLabel.setAttribute('data-progress-label', '');
        const progressCount = doc.createElement('span');
        progressCount.className = 'progress-count';
        progressCount.setAttribute('data-progress-count', '');
        const cancel = commandButton('cancel', 'Cancel');
        progress.append(progressLabel, progressCount, cancel);
        instrument.append(shellRow, featureRow, settings, panelContent, progress);
        shadowRoot.append(style, launcher, instrument);
        doc.body.appendChild(host);
        const selectionListeners = new Set();
        const currentThumbsById = new Map();
        const selectedRefsById = new Map();
        const itemStatesById = new Map();
        const thumbs = [];
        let modeOn = false;
        let destroyed = false;
        let observer = null;
        let reconcileTimer = null;
        let syncSelection = () => { };
        const selection = createSelection(() => syncSelection());
        function selectionSnapshot() {
            const snapshot = [];
            for (const id of selection.ids()) {
                const selected = selectedRefsById.get(id);
                if (selected)
                    snapshot.push(selected);
            }
            return Object.freeze(snapshot);
        }
        function selectedPostRef(thumb) {
            return Object.freeze({
                site: context.page.normalizedHostname,
                id: thumb.id,
                md5: thumb.md5,
                thumbUrl: thumb.thumbUrl,
            });
        }
        function refreshThumbs() {
            const galleryRoot = doc.querySelector(config.galleryRootSelector);
            const scanned = scanThumbs(galleryRoot ?? doc, config);
            for (const thumb of thumbs) {
                thumb.anchor.classList.remove('boorukit-thumb', 'boorukit-selected', 'boorukit-failed');
                thumb.anchor.removeAttribute('title');
            }
            thumbs.splice(0, thumbs.length, ...scanned);
            currentThumbsById.clear();
            for (const thumb of scanned) {
                currentThumbsById.set(thumb.id, thumb);
                if (selection.has(thumb.id)) {
                    selectedRefsById.set(thumb.id, selectedPostRef(thumb));
                }
            }
        }
        function decorateThumbs() {
            for (const thumb of thumbs) {
                thumb.anchor.classList.add('boorukit-thumb');
                thumb.anchor.classList.toggle('boorukit-selected', modeOn && selection.has(thumb.id));
                const itemState = itemStatesById.get(thumb.id);
                applyThumbState(thumb.anchor, itemState?.state ?? 'idle', itemState?.reason ?? null);
            }
        }
        syncSelection = () => {
            for (const id of [...selectedRefsById.keys()]) {
                if (!selection.has(id))
                    selectedRefsById.delete(id);
            }
            for (const id of selection.ids()) {
                const current = currentThumbsById.get(id);
                if (current)
                    selectedRefsById.set(id, selectedPostRef(current));
            }
            const visibleSelectedCount = thumbs.reduce((total, thumb) => total + (selection.has(thumb.id) ? 1 : 0), 0);
            count.textContent = String(selection.size());
            selectPage.hidden = thumbs.length > 0
                && visibleSelectedCount >= thumbs.length;
            decorateThumbs();
            const snapshot = selectionSnapshot();
            for (const listener of selectionListeners)
                listener(snapshot);
        };
        function stopObserver() {
            observer?.disconnect();
            observer = null;
            if (reconcileTimer !== null) {
                doc.defaultView?.clearTimeout(reconcileTimer);
                reconcileTimer = null;
            }
        }
        function startObserver() {
            const view = doc.defaultView;
            const galleryRoot = doc.querySelector(config.galleryRootSelector);
            if (!view || !galleryRoot || typeof view.MutationObserver !== 'function') {
                return;
            }
            observer = new view.MutationObserver(() => {
                if (reconcileTimer !== null)
                    return;
                reconcileTimer = view.setTimeout(() => {
                    reconcileTimer = null;
                    if (!modeOn || destroyed)
                        return;
                    refreshThumbs();
                    syncSelection();
                }, 40);
            });
            observer.observe(galleryRoot, { childList: true, subtree: true });
        }
        const clickHandler = createThumbClickHandler(thumbs, selection, config, () => modeOn);
        doc.addEventListener('click', clickHandler, true);
        const actions = new Map();
        const panels = new Map();
        const settingEntries = new Map();
        const statuses = new Map();
        let panelCleanup = null;
        let activePanelId = null;
        const selectionApi = Object.freeze({
            snapshot: selectionSnapshot,
            has(site, id) {
                const normalized = site.toLowerCase().replace(/^www\./, '');
                return normalized === context.page.normalizedHostname
                    && selection.has(id);
            },
            size: () => selection.size(),
            subscribe(listener) {
                selectionListeners.add(listener);
                return () => { selectionListeners.delete(listener); };
            },
        });
        const api = Object.freeze({
            selection: selectionApi,
            registerAction(definition) {
                const button = doc.createElement('button');
                button.type = 'button';
                button.setAttribute('data-action', definition.id);
                button.textContent = definition.label;
                button.addEventListener('click', () => {
                    void Promise.resolve(definition.run(selectionSnapshot())).catch((error) => context.diagnostics.report('deck', error));
                });
                actions.set(definition.id, { definition, button });
                featureRow.appendChild(button);
                return () => {
                    const current = actions.get(definition.id);
                    if (current?.button === button)
                        actions.delete(definition.id);
                    button.remove();
                };
            },
            registerPanel(definition) {
                if (panels.has(definition.id)) {
                    throw new Error(`duplicate panel id: ${definition.id}`);
                }
                const button = doc.createElement('button');
                button.type = 'button';
                button.setAttribute('data-panel', definition.id);
                button.textContent = definition.label;
                button.addEventListener('click', () => {
                    panelCleanup?.();
                    panelCleanup = null;
                    activePanelId = null;
                    panelContent.replaceChildren();
                    const cleanup = definition.render(panelContent);
                    panelCleanup = typeof cleanup === 'function' ? cleanup : null;
                    activePanelId = definition.id;
                    panelContent.hidden = false;
                });
                panels.set(definition.id, { definition, button });
                featureRow.appendChild(button);
                return () => {
                    const current = panels.get(definition.id);
                    if (current?.button === button)
                        panels.delete(definition.id);
                    button.remove();
                    if (current?.button === button
                        && activePanelId === definition.id) {
                        panelCleanup?.();
                        panelCleanup = null;
                        activePanelId = null;
                        panelContent.replaceChildren();
                        panelContent.hidden = true;
                    }
                };
            },
            registerSetting(definition) {
                const label = doc.createElement('label');
                label.className = 'setting';
                const input = doc.createElement('input');
                input.type = 'checkbox';
                input.setAttribute('data-setting', definition.id);
                const name = doc.createElement('span');
                const note = doc.createElement('small');
                note.className = 'setting-note';
                label.append(input, name, note);
                const entry = { definition, label, name, note, input };
                const render = () => {
                    name.textContent = entry.definition.label;
                    input.checked = entry.definition.checked;
                    note.textContent = entry.definition.reloadRequired
                        ? 'Reload required'
                        : '';
                };
                input.addEventListener('change', () => {
                    void Promise.resolve(entry.definition.onChange(input.checked)).catch((error) => context.diagnostics.report('deck', error));
                });
                settingEntries.set(definition.id, entry);
                render();
                settingsList.appendChild(label);
                return Object.freeze({
                    update(next) {
                        entry.definition = next;
                        render();
                    },
                    dispose() {
                        if (settingEntries.get(definition.id) === entry) {
                            settingEntries.delete(definition.id);
                        }
                        label.remove();
                    },
                });
            },
            setItemState(id, state, reason) {
                itemStatesById.set(id, Object.freeze({ state, reason }));
                const current = currentThumbsById.get(id);
                if (modeOn && current) {
                    applyThumbState(current.anchor, state, reason ?? null);
                }
            },
            setFeatureStatus(featureId, status, reason) {
                statuses.get(featureId)?.remove();
                statuses.delete(featureId);
                if (status === 'available')
                    return;
                const message = doc.createElement('span');
                message.className = 'status';
                message.setAttribute('data-feature-status', featureId);
                message.setAttribute('data-state', status);
                const label = featureId === 'grabber' ? 'Grabber' : 'Pool Party';
                message.textContent = `${label} unavailable${reason ? `: ${reason}` : ''}`;
                statuses.set(featureId, message);
                featureRow.appendChild(message);
            },
        });
        function open() {
            if (destroyed || modeOn)
                return;
            modeOn = true;
            refreshThumbs();
            startObserver();
            doc.body.classList.add('boorukit-selection-armed');
            launcher.hidden = true;
            instrument.hidden = false;
            syncSelection();
        }
        function close() {
            if (!modeOn)
                return;
            modeOn = false;
            stopObserver();
            doc.body.classList.remove('boorukit-selection-armed');
            instrument.hidden = true;
            launcher.hidden = false;
            for (const thumb of thumbs) {
                thumb.anchor.classList.remove('boorukit-thumb', 'boorukit-selected', 'boorukit-failed');
                thumb.anchor.removeAttribute('title');
            }
        }
        launcher.addEventListener('click', open);
        closeButton.addEventListener('click', close);
        selectPage.addEventListener('click', () => {
            for (const thumb of thumbs) {
                if (!selection.has(thumb.id))
                    selection.toggle(thumb.id);
            }
        });
        clear.addEventListener('click', () => {
            itemStatesById.clear();
            selection.clear();
            for (const thumb of thumbs)
                applyThumbState(thumb.anchor, 'idle', null);
        });
        settingsButton.addEventListener('click', () => {
            settings.hidden = !settings.hidden;
        });
        cancel.addEventListener('click', () => context.tasks.cancel());
        const unsubscribeProgress = context.tasks.subscribe((state) => {
            if (!state) {
                progress.hidden = true;
                progressLabel.textContent = '';
                progressCount.textContent = '';
                return;
            }
            progress.hidden = false;
            progressLabel.textContent = state.label;
            progressCount.textContent = `${state.completed}/${state.total}`;
        });
        function destroy() {
            if (destroyed)
                return;
            close();
            destroyed = true;
            unsubscribeProgress();
            panelCleanup?.();
            panelCleanup = null;
            activePanelId = null;
            selectionListeners.clear();
            doc.removeEventListener('click', clickHandler, true);
            host.remove();
        }
        return Object.freeze({ api, shadowRoot, open, close, destroy });
    }
    function createDeckDescriptor() {
        return {
            kind: 'deck',
            id: 'deck',
            label: 'Deck',
            apiVersion: 1,
            defaultEnabled: true,
            required: true,
            supports: (page) => {
                const config = resolveSiteConfig(page.hostname);
                return config !== null
                    && isGalleryPage(page.url, config.galleryUrlKind);
            },
            activate: (context) => createDeckController(context).api,
        };
    }
    function registerDeck(runtime) {
        runtime.register(createDeckDescriptor());
    }
    function registerGlobalDeck() {
        const key = Symbol.for('codeberg.org/0xdev/boorukit/runtime/v1');
        const runtimes = globalThis;
        const runtime = runtimes[key];
        if (!runtime || runtime.apiVersion !== 1) {
            throw new Error('BooruKit Core API 1 is not available');
        }
        runtime.register(createDeckDescriptor());
    }
    if (typeof window !== 'undefined')
        registerGlobalDeck();
    if (typeof window === 'undefined'
        && typeof module !== 'undefined'
        && module.exports) {
        module.exports = {
            PAGE_CSS,
            applyThumbState,
            createDeckController,
            createDeckDescriptor,
            createSelection,
            createThumbClickHandler,
            isGalleryPage,
            postIdFromHref,
            registerDeck,
            resolveSiteConfig,
            scanThumbs,
        };
    }
})();