made by ai and me.
// ==UserScript==
// @name Relationship Checker v3
// @namespace http://tampermonkey.net/
// @version 3.3
// @license MIT
// @description made by ai and me.
// @match https://f95zone.to/*
// @match https://www.f95zone.to/*
// @match https://lewdcorner.com/*
// @match https://www.lewdcorner.com/*
// @match https://allthefallen.moe/forum/*
// @match https://www.allthefallen.moe/forum/*
// @match https://platinmods.com/*
// @match https://www.platinmods.com/*
// @match https://taboo-game.com/*
// @match https://www.taboo-game.com/*
// @match https://incgrepacks.com/*
// @match https://www.incgrepacks.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=f95zone.to
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect gist.githubusercontent.com
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
/* ================= GLOBAL STATE & CONFIG ================= */
const CONFIG = { PAGE_SIZE: 30 };
const CLOUD_URL = 'https://gist.githubusercontent.com/oberold/aeb49b8f81691f36a5213a81f044f807/raw/gamelib.json';
const SETTINGS = Object.assign({
accent: '#8b5cf6',
theme: 'auto',
compactMode: false,
tagLogic: 'AND',
customTags: '{}'
}, JSON.parse(GM_getValue('RC_Settings', '{}')));
const FAVORITES = new Set(JSON.parse(GM_getValue('RC_Favorites', '[]')));
const STATE = {
games: [], facets: { engines: [], statuses: [], tags: [] },
uiHost: null, shadowRoot: null, dbLoaded: false, scanComplete: false,
focusedIndex: -1, pinnedGameId: null, isFetching: false, activeTotal: 0,
activeData: []
};
/* ================= DICTIONARIES & GROUPS ================= */
const MODIFIERS = ['true', 'adopted', 'step', 'blood', 'half', 'fake', 'foster', 'others', 'other', 'mod'];
const JUNK_TAGS = ['false', 'none', 'n/a', 'incest'];
let TAG_MAP = {
'm/s': 'Mother / Son', 'f/d': 'Father / Daughter', 'm/d': 'Mother / Daughter', 'f/s': 'Father / Son',
'b/s': 'Brother / Sister', 's/s': 'Sister / Sister', 'b/b': 'Brother / Brother',
'a/np': 'Aunt / Nephew', 'u/nc': 'Uncle / Niece', 'a/nc': 'Aunt / Niece', 'u/np': 'Uncle / Nephew',
'fc/mc': 'Female Cousin / Male Cousin', 'fc/fc': 'Female Cousin / Female Cousin', 'mc/mc': 'Male Cousin / Male Cousin',
'gm/gs': 'Grandmother / Grandson', 'gm/gd': 'Grandmother / Granddaughter', 'gf/gd': 'Grandfather / Granddaughter', 'gf/gs': 'Grandfather / Grandson',
'ggm/ggs': 'Great-Grandmother / Great-Grandson', 'ggm/ggd': 'Great-Grandmother / Great-Granddaughter',
'ggf/ggd': 'Great-Grandfather / Great-Granddaughter', 'ggf/ggs': 'Great-Grandfather / Great-Grandson',
'mc': 'Main Character'
};
const TAG_GROUPS = {
'Immediate Family': ['m/s', 'f/d', 'm/d', 'f/s', 'b/s', 's/s', 'b/b'],
'Extended Family': ['a/np', 'u/nc', 'a/nc', 'u/np', 'fc/mc', 'fc/fc', 'mc/mc'],
'Generational': ['gm/gs', 'gm/gd', 'gf/gd', 'gf/gs', 'ggm/ggs', 'ggm/ggd', 'ggf/ggd', 'ggf/ggs'],
'Other': ['mc']
};
const DEFINITIONS = {
'true': 'Sexual activity between blood-related individuals.',
'blood': 'Sexual activity between blood-related individuals.',
'step': 'Connected through legal marriage. No blood relation.',
'half': 'Individuals who share one biological parent.',
'adopted': 'Legally adopted into the same family.',
'foster': 'Temporary or foster family arrangement.',
'others': 'Blood-related individuals not involving the main character.',
'other': 'Blood-related individuals not involving the main character.',
'mod': 'Unofficial modification adding incest content.'
};
try { Object.assign(TAG_MAP, JSON.parse(SETTINGS.customTags)); } catch(e) {}
function getFullTagName(abbr) {
const clean = abbr.toLowerCase().replace(/[^a-z/]/g, '');
return TAG_MAP[clean] || abbr.toUpperCase();
}
function getEngineName(engine) {
const e = (engine || '').toLowerCase();
if (e.includes('ren')) return "Ren'Py";
if (e.includes('unity')) return 'Unity';
if (e.includes('rpg') || e.includes('mv') || e.includes('mz') || e.includes('vx')) return 'RPG Maker';
if (e.includes('html') || e.includes('twine')) return 'HTML/Twine';
if (e.includes('unreal')) return 'Unreal Engine';
return 'Unknown Engine';
}
function getStatusData(status) {
const s = (status || '').toLowerCase();
if (s.includes('completed') || s.includes('finished')) return { color: '#10b981', label: 'Completed' };
if (s.includes('abandoned') || s.includes('cancelled') || s.includes('dropped')) return { color: '#ef4444', label: 'Abandoned' };
if (s.includes('on hold') || s.includes('hiatus')) return { color: '#f59e0b', label: 'On Hold' };
return { color: '#3b82f6', label: 'Ongoing' };
}
function getBigrams(str) {
const bigrams = new Set();
for (let i = 0; i < str.length - 1; i++) bigrams.add(str.slice(i, i + 2));
return bigrams;
}
function fuzzySimilarity(s1, s2) {
if (!s1 || !s2) return 0;
const bg1 = getBigrams(s1.toLowerCase().replace(/\s+/g, ''));
const bg2 = getBigrams(s2.toLowerCase().replace(/\s+/g, ''));
let intersection = 0;
for (let bg of bg1) if (bg2.has(bg)) intersection++;
return (2.0 * intersection) / (Math.max(1, bg1.size + bg2.size));
}
/* ================= DATA PROCESSING ================= */
function parseTags(relString) {
if (!relString) return [];
const tokens = relString.split(/\s+/).filter(Boolean);
const results = [];
let activeMod = 'true';
let isMod = false;
tokens.forEach(token => {
let cleanToken = token.toLowerCase().trim().replace(/\*$/, '');
if (cleanToken === 'mod') {
isMod = true;
} else if (MODIFIERS.includes(cleanToken)) {
activeMod = cleanToken;
} else if (!JUNK_TAGS.includes(cleanToken) && cleanToken.length > 0) {
let category = 'other';
if (['true', 'blood'].includes(activeMod)) category = 'blood';
else if (['adopted', 'step', 'half', 'fake', 'foster'].includes(activeMod)) category = 'step';
results.push({ base: cleanToken, modifier: activeMod, category: category, full: `${activeMod} ${cleanToken}`.trim(), isMod: isMod });
isMod = false;
}
});
const unique = []; const seen = new Set();
results.forEach(r => {
const key = `${r.modifier}-${r.base}-${r.isMod}`;
if (!seen.has(key)) { seen.add(key); unique.push(r); }
});
return unique;
}
/* ================= PRIVILEGED NETWORK ENGINE ================= */
function fetchCloudData(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET", url: url, nocache: true, timeout: 10000,
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
try { resolve(JSON.parse(response.responseText)); }
catch (e) { reject(new Error("Invalid JSON format.")); }
} else { reject(new Error(`HTTP Status ${response.status}`)); }
},
onerror: function() { reject(new Error("Network connection failed.")); },
ontimeout: function() { reject(new Error("Request timed out.")); }
});
});
}
function buildDatabase(data) {
const engines = new Set(), statuses = new Set(), baseTags = new Set();
STATE.games = data.map((g, index) => {
g._id = `game_${index}`;
g.parsedTags = parseTags(g.relationships);
let rawStatus = g.status || '';
let engineField = g.engine || '';
let determinedEngine = getEngineName(engineField);
if (determinedEngine === 'Unknown Engine') {
determinedEngine = getEngineName(rawStatus) !== 'Unknown Engine' ? getEngineName(rawStatus) : 'Unknown Engine';
}
g.cleanEngine = determinedEngine;
let sData = getStatusData(rawStatus);
g.cleanStatus = sData.label;
g.statusColor = sData.color;
// Extract the Dev Note by stripping standard statuses and engine keywords aggressively
let note = rawStatus
.replace(/completed|finished|abandoned|cancelled|dropped|on hold|hiatus|ongoing/ig, '')
.replace(/ren'py|renpy|unity|rpg maker|rpgm|rpg\s*m|rpg|html|twine|unreal/ig, '') // Added rpgm and rpg m fixes
.replace(/^[*[\]()\-\s]+/, '') // strip leading asterisks, brackets, dashes
.replace(/[*[\]()\-\s]+$/, '') // strip trailing garbage
.trim();
// Garbage Collection: Nuke leftover isolated engine acronyms (M, MV, MZ, MAC, PC)
if (/^(m|mv|mz|vx|mac|pc)$/i.test(note)) {
note = '';
}
g.devNote = note;
if(g.cleanEngine !== 'Unknown Engine') engines.add(g.cleanEngine);
statuses.add(g.cleanStatus);
g.parsedTags.forEach(pt => baseTags.add(pt.base));
return g;
});
STATE.facets = {
engines: [...engines].sort(), statuses: [...statuses].sort(),
tags: [...baseTags].sort((a,b) => getFullTagName(a).localeCompare(getFullTagName(b)))
};
STATE.dbLoaded = true;
}
function getBaseFilteredList(filters) {
let list = [...STATE.games];
if (filters.favsOnly) list = list.filter(g => FAVORITES.has(g._id));
const query = (filters.search || '').trim().toLowerCase();
if (query) {
const tokens = query.split(/\s+/).filter(Boolean);
list = list.map(g => {
const targetText = [(g.title_normalized || g.title), ...(g.parsedTags.map(pt => pt.full + ' ' + getFullTagName(pt.base)))].join(' ').toLowerCase();
const exactMatch = tokens.every(t => targetText.includes(t));
const fuzzScore = fuzzySimilarity(query, g.title_normalized || g.title);
return { game: g, score: exactMatch ? 1 : fuzzScore };
}).filter(item => item.score > 0.35).sort((a, b) => b.score - a.score).map(item => item.game);
}
if (filters.engine) list = list.filter(g => g.cleanEngine === filters.engine);
if (filters.status) list = list.filter(g => g.cleanStatus === filters.status);
return list;
}
function getFilteredList(filters) {
let list = getBaseFilteredList(filters);
const incTags = Object.keys(filters.tags).filter(t => filters.tags[t] === 'include');
const excTags = Object.keys(filters.tags).filter(t => filters.tags[t] === 'exclude');
if (incTags.length > 0) {
if (SETTINGS.tagLogic === 'AND') list = list.filter(g => incTags.every(ft => g.parsedTags.some(pt => pt.base === ft)));
else list = list.filter(g => incTags.some(ft => g.parsedTags.some(pt => pt.base === ft)));
}
if (excTags.length > 0) {
list = list.filter(g => !excTags.some(ft => g.parsedTags.some(pt => pt.base === ft)));
}
return list;
}
function runFilter(filters, page) {
let list = getFilteredList(filters);
if (filters.sort === 'az') list.sort((a,b) => (a.title || '').localeCompare(b.title || ''));
if (filters.sort === 'za') list.sort((a,b) => (b.title || '').localeCompare(a.title || ''));
if (STATE.pinnedGameId && !filters.search && !filters.sort && !filters.favsOnly && page === 0) {
const pinnedIndex = list.findIndex(g => g._id === STATE.pinnedGameId);
if (pinnedIndex > -1) {
const pinnedGame = list.splice(pinnedIndex, 1)[0];
list.unshift(pinnedGame);
}
}
return { total: list.length, items: list.slice(page * CONFIG.PAGE_SIZE, (page * CONFIG.PAGE_SIZE) + CONFIG.PAGE_SIZE), query: filters.search };
}
async function initData() {
try {
const data = await fetchCloudData(CLOUD_URL);
buildDatabase(data);
updateToggleButtonState();
scanPageForLiveChecker();
if (STATE.uiHost && STATE.uiHost.classList.contains('open')) {
const search = STATE.uiHost.querySelector('#real-search');
if(search) search.dispatchEvent(new Event('input'));
}
} catch (err) {
console.error("Cloud Engine Error:", err);
const btn = STATE.shadowRoot?.querySelector('.fab');
if (btn) btn.innerHTML = `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="#ef4444" stroke-width="3" fill="none"/></svg>`;
}
}
/* ================= TITANIUM MASS MATCH & LIVE CHECKER ================= */
function scanPageForLiveChecker() {
if (!STATE.dbLoaded) return;
const nodes = document.querySelectorAll('h1.p-title-value, h1.entry-title, h1, .structItem-title a, .thread-title a, h3.title a');
let nodesProcessed = false;
nodes.forEach(node => {
nodesProcessed = true;
if (node.dataset.rcScanned) return;
if (node.closest('.message-body, .bbWrapper, .message-content, blockquote, .quote')) {
node.dataset.rcScanned = 'true'; return;
}
const rawText = node.textContent;
const cleanTitle = rawText.replace(/\[.*?\]|\(.*?\)/g, '').replace(/v\d+(\.\d+)*/gi, '').trim();
if (!cleanTitle || cleanTitle.length < 3) return;
const isH1 = node.tagName === 'H1' || node.classList.contains('p-title-value');
let urlSlug = '';
if (isH1) {
try { urlSlug = window.location.pathname.split('/').filter(Boolean).pop().split('.')[0].replace(/-/g, ' '); }
catch(e) { urlSlug = ''; }
}
let bestMatch = null, highestScore = 0;
STATE.games.forEach(g => {
const tTitle = g.title_normalized || g.title;
const score1 = fuzzySimilarity(cleanTitle, tTitle);
const score2 = urlSlug ? fuzzySimilarity(urlSlug, tTitle) : 0;
const bestScore = Math.max(score1, score2);
if (bestScore > highestScore && bestScore > 0.8) { highestScore = bestScore; bestMatch = g; }
});
if (bestMatch) {
node.dataset.rcScanned = 'true';
const badgeColor = 'var(--success)';
if (isH1) {
STATE.pinnedGameId = bestMatch._id;
const fab = STATE.shadowRoot.querySelector('.fab');
if (fab) {
fab.classList.add('match-found');
const limitedTags = bestMatch.parsedTags.slice(0, 3).map(t => t.base.toUpperCase()).join(', ');
const extra = bestMatch.parsedTags.length > 3 ? ` +${bestMatch.parsedTags.length - 3}` : '';
fab.innerHTML = `
<div class="fab-mini-tags">${limitedTags}${extra}</div>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L15 9l7 3-7 3-3 7-3-7-7-3 7-3z"/></svg>
`;
}
} else {
const badge = document.createElement('span');
badge.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: text-top; margin-right:4px;"><path d="M12 2L15 9l7 3-7 3-3 7-3-7-7-3 7-3z"/></svg>Library Match`;
badge.style.cssText = `
display: inline-flex; align-items:center; margin-left: 8px; padding: 2px 6px; border-radius: 6px;
background: ${badgeColor}20; color: ${badgeColor}; border: 1px solid ${badgeColor}50;
font-size: 10px; font-weight: 700; vertical-align: middle; cursor: help;
`;
badge.title = bestMatch.parsedTags.map(t => t.full.toUpperCase()).join(' | ');
node.parentNode.insertBefore(badge, node.nextSibling);
if (bestMatch.cleanStatus.toLowerCase() === 'abandoned' || bestMatch.cleanStatus.toLowerCase() === 'on hold') {
const container = node.closest('.structItem, .thread, .item');
if (container) {
container.style.opacity = '0.35'; container.style.filter = 'grayscale(100%)'; container.style.transition = '0.3s ease';
container.addEventListener('mouseenter', () => { container.style.opacity = '1'; container.style.filter = 'none'; });
container.addEventListener('mouseleave', () => { container.style.opacity = '0.35'; container.style.filter = 'grayscale(100%)'; });
}
}
}
} else {
let hasIncestTag = false;
const searchKeywords = ['incest', 'taboo', 'family'];
if (isH1) {
const pageTags = document.querySelectorAll('.tagItem');
pageTags.forEach(t => { if(searchKeywords.some(k => t.textContent.toLowerCase().includes(k))) hasIncestTag = true; });
} else {
const container = node.closest('.structItem');
if (container) {
const rowTags = container.querySelectorAll('.tagItem');
rowTags.forEach(t => { if(searchKeywords.some(k => t.textContent.toLowerCase().includes(k))) hasIncestTag = true; });
}
}
if (hasIncestTag) {
node.dataset.rcScanned = 'true';
const badgeColor = '#8b5cf6';
const badge = document.createElement('span');
badge.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: text-top; margin-right:4px;"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>Uncharted`;
badge.style.cssText = `
display: inline-flex; align-items:center; margin-left: 8px; padding: 2px 6px; border-radius: 6px;
background: ${badgeColor}20; color: ${badgeColor}; border: 1px solid ${badgeColor}50;
font-size: 10px; font-weight: 700; vertical-align: middle; cursor: help;
`;
badge.title = "This thread has taboo tags but is not in your personal library.";
node.parentNode.insertBefore(badge, node.nextSibling);
} else {
node.dataset.rcScanned = 'true';
}
}
});
if (nodesProcessed) STATE.scanComplete = true;
}
new MutationObserver(() => scanPageForLiveChecker()).observe(document.body, { childList: true, subtree: true });
/* ================= LUMA THEMING ENGINE ================= */
function applyLumaTheme(root) {
let theme = SETTINGS.theme;
if (theme === 'auto') {
const bg = window.getComputedStyle(document.body).backgroundColor;
const rgb = bg.match(/\d+/g);
if (rgb && rgb.length >= 3) {
const luma = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
theme = luma > 150 ? 'light' : 'dark';
} else { theme = 'dark'; }
}
root.querySelector('.palette-container').setAttribute('data-theme', theme);
}
/* ================= UI SETUP ================= */
function initUI() {
const host = document.createElement('div');
host.id = 'relcheck-apex-host';
host.style.cssText = 'position: fixed; top: 0; left: 0; width: 0; height: 0; z-index: 2147483647; overflow: visible;';
document.documentElement.appendChild(host);
STATE.shadowRoot = host.attachShadow({ mode: 'closed' });
const style = document.createElement('style');
style.textContent = `
:host {
--accent: ${SETTINGS.accent};
--accent-bg: ${SETTINGS.accent}20;
--success: #10b981;
--danger: #ef4444;
--font: 'Inter', system-ui, sans-serif;
--font-mono: ui-monospace, monospace;
--spring: cubic-bezier(0.4, 0, 0.2, 1);
--glide: cubic-bezier(0.4, 0, 0.2, 1);
}
.palette-container[data-theme="dark"] {
--bg: rgba(14, 16, 21, 0.88); --bg-solid: #0e1015; --bg-glass: rgba(14, 16, 21, 0.6);
--bg-hover: rgba(255, 255, 255, 0.04); --bg-card: rgba(255, 255, 255, 0.02);
--border: rgba(255, 255, 255, 0.08);
--text-main: #f8fafc; --text-muted: #94a3b8; --text-ghost: rgba(255,255,255,0.25);
}
.palette-container[data-theme="light"] {
--bg: rgba(250, 250, 252, 0.95); --bg-solid: #f8fafc; --bg-glass: rgba(250, 250, 252, 0.7);
--bg-hover: rgba(0, 0, 0, 0.04); --bg-card: rgba(0, 0, 0, 0.02);
--border: rgba(0, 0, 0, 0.1);
--text-main: #0f172a; --text-muted: #64748b; --text-ghost: rgba(0,0,0,0.25);
}
.backdrop { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.3); opacity: 0; display: none; transition: opacity 0.3s var(--glide); z-index: 999998; backdrop-filter: blur(2px); }
.palette-container {
position: fixed; top: 50vh; left: 50vw; transform: translate(-50%, -50%) scale(0.95); width: 860px; max-width: 95vw; height: 85vh; max-height: 850px;
background: var(--bg); backdrop-filter: blur(40px) saturate(200%);
border: 1px solid var(--border); border-radius: 16px; color: var(--text-main); font-family: var(--font); display: none; flex-direction: column; opacity: 0;
box-shadow: 0 40px 100px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.1); transition: opacity 0.3s ease, transform 0.4s var(--spring); z-index: 999999; overflow: hidden;
}
.palette-container.open { display: flex; opacity: 1; transform: translate(-50%, -50%) scale(1); }
.palette-container.closing { opacity: 0; transform: translate(-50%, -50%) scale(0.95); transition: opacity 0.2s ease, transform 0.2s ease; }
.sticky-header { position: sticky; top: 0; z-index: 50; background: var(--bg-glass); backdrop-filter: blur(24px); border-bottom: 1px solid var(--border); display: flex; flex-direction: column; }
.search-header { display: flex; align-items: center; padding: 20px 24px; position: relative; }
.search-header svg.icon-search { width: 22px; height: 22px; fill: var(--text-muted); margin-right: 16px; }
.search-wrapper { position: relative; flex: 1; display: flex; align-items: center; }
.search-bar { width: 100%; background: transparent; border: none; color: var(--text-main); font-size: 22px; outline: none; font-family: var(--font); font-weight: 500; letter-spacing: -0.01em; position: relative; z-index: 2; }
.search-bar::placeholder { color: var(--text-muted); opacity: 0.6; font-weight: 400; }
.search-ghost {
position: absolute; top: 0; left: 0; width: 100%; color: var(--text-ghost); font-size: 22px; font-weight: 500; font-family: var(--font); pointer-events: none; z-index: 1; white-space: pre;
opacity: 0; transform: translateX(8px); transition: opacity 0.25s ease, transform 0.25s var(--spring); will-change: transform, opacity;
}
.search-ghost.active { opacity: 1; transform: translateX(0); }
.header-actions { display: flex; gap: 8px; margin-left: 16px; }
.icon-btn { background: transparent; border: 1px solid transparent; color: var(--text-muted); padding: 8px; border-radius: 8px; cursor: pointer; transition: 0.2s var(--glide); display: flex; align-items: center; justify-content: center; }
.icon-btn:hover { background: var(--bg-hover); color: var(--text-main); border-color: var(--border); }
.icon-btn.active { background: var(--accent-bg); color: var(--accent); border-color: var(--accent); }
.icon-btn svg { width: 18px; height: 18px; fill: currentColor; }
.filters-row { display: flex; align-items: center; gap: 10px; padding: 0 24px 12px 24px; flex-wrap: wrap;}
.select-wrapper { position: relative; display: inline-block; max-width: 160px; }
.select-wrapper select { appearance: none; background: var(--bg-solid); border: 1px solid var(--border); color: var(--text-main); padding: 8px 30px 8px 14px; border-radius: 20px; font-size: 12px; font-weight: 600; cursor: pointer; font-family: var(--font); outline: none; transition: 0.2s; box-shadow: 0 2px 8px rgba(0,0,0,0.1); width: 100%; text-overflow: ellipsis; white-space: nowrap; overflow: hidden;}
.select-wrapper select:hover { border-color: var(--accent); }
.select-wrapper::after { content: ''; position: absolute; right: 14px; top: 50%; transform: translateY(-50%); width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid var(--text-muted); pointer-events: none; }
.filter-btn { background: var(--bg-solid); border: 1px solid var(--border); color: var(--text-main); padding: 8px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; cursor: pointer; transition: 0.2s; font-family: var(--font); box-shadow: 0 2px 8px rgba(0,0,0,0.1); display:flex; align-items:center; gap:6px;}
.filter-btn:hover { border-color: var(--accent); color: var(--accent); }
.filter-btn.active { background: var(--accent-bg); color: var(--accent); border-color: var(--accent); }
.active-filters-container { display: flex; gap: 8px; padding: 0 24px 12px 24px; flex-wrap: wrap; }
.pill-filter { display: flex; align-items: center; gap: 6px; border: 1px solid; padding: 6px 12px; border-radius: 8px; font-size: 11px; font-weight: 600; cursor: pointer; transition: 0.2s;}
.pill-filter.include { background: rgba(16,185,129,0.15); color: var(--success); border-color: var(--success); }
.pill-filter.include:hover { background: rgba(16,185,129,0.25); }
.pill-filter.exclude { background: rgba(239,68,68,0.15); color: var(--danger); border-color: var(--danger); text-decoration: line-through; }
.pill-filter.exclude:hover { background: rgba(239,68,68,0.25); }
/* --- CONTEXTUAL POPOVER (TAGS) --- */
.popover-anchor { position: relative; display: inline-block; }
.tag-popover {
position: absolute; top: calc(100% + 8px); left: 0; width: 500px; max-height: 400px;
background: var(--bg-solid); border: 1px solid var(--border); border-radius: 12px;
box-shadow: 0 20px 40px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05);
display: none; flex-direction: column; opacity: 0; transform: translateY(-10px);
transition: 0.2s var(--spring); z-index: 100; overflow: hidden;
}
.tag-popover.active { display: flex; opacity: 1; transform: translateY(0); }
.tm-header { display: flex; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--border); gap: 12px; background: var(--bg-card); }
.tm-search { flex: 1; background: transparent; border: none; color: var(--text-main); font-size: 14px; outline: none; font-family: var(--font); }
.tm-search::placeholder { color: var(--text-muted); }
.tm-clear { font-size: 10px; text-transform: uppercase; font-weight: 700; color: var(--text-muted); cursor: pointer; padding: 4px 8px; border-radius: 6px; transition: 0.2s; background: transparent; border:none;}
.tm-clear:hover { background: rgba(239,68,68,0.2); color: var(--danger); }
.tm-body { padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; }
.tm-section-title { font-size: 10px; text-transform: uppercase; color: var(--text-muted); font-weight: 800; letter-spacing: 1px; margin-bottom: 10px; border-bottom: 1px solid var(--border); padding-bottom: 4px;}
.tm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
.tm-btn {
display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-radius: 6px;
border: 1px solid var(--border); background: var(--bg); cursor: pointer; transition: 0.15s ease;
font-size: 12px; font-weight: 600; color: var(--text-muted); font-family: var(--font-mono);
}
.tm-btn:hover { border-color: var(--text-muted); color: var(--text-main); }
.tm-btn.include { background: rgba(16,185,129,0.1); border-color: var(--success); color: var(--success); }
.tm-btn.exclude { background: rgba(239,68,68,0.1); border-color: var(--danger); color: var(--danger); text-decoration: line-through; }
.tm-btn.disabled { opacity: 0.4; }
.tm-count { font-size: 10px; opacity: 0.6; }
/* --- GHOST SCROLLBAR --- */
.results-area { flex: 1; overflow-y: scroll; position: relative; scroll-behavior: smooth;}
.results-area::-webkit-scrollbar { width: 8px; background: transparent; }
.results-area::-webkit-scrollbar-thumb {
background: transparent; border-radius: 10px;
box-shadow: inset 0 0 0 10px rgba(128,128,128,0.15); border: 2px solid transparent; background-clip: padding-box;
}
.results-area:hover::-webkit-scrollbar-thumb { box-shadow: inset 0 0 0 10px rgba(128,128,128,0.4); }
/* --- LIST & ANIMATIONS --- */
@keyframes glideUp { from { opacity: 0; transform: translateY(15px); } to { opacity: 1; transform: translateY(0); } }
.list-row { display: flex; flex-direction: column; padding: 16px 24px; border-bottom: 1px solid var(--border); transition: background 0.2s var(--glide); cursor: pointer; user-select: none; animation: glideUp 0.4s var(--spring) both; }
.list-row:hover, .list-row.keyboard-focus { background: var(--bg-hover); }
.list-row.keyboard-focus { box-shadow: inset 3px 0 0 var(--accent); }
.list-row:last-child { border-bottom: none; }
.row-visible { display: flex; justify-content: space-between; align-items: center; gap: 16px; width: 100%; position: relative;}
.row-main { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; cursor: help; }
/* ENGINE BRAND BADGES */
.engine-text { font-size: 11px; font-weight: 600; color: var(--text-main); background: rgba(255,255,255,0.05); padding: 4px 10px; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); letter-spacing: 0.5px; backdrop-filter: blur(8px); transition: 0.2s; }
.palette-container[data-theme="light"] .engine-text { background: rgba(0,0,0,0.03); border: 1px solid rgba(0,0,0,0.08); }
.engine-text[data-engine="Ren'Py"] { color: #f472b6; background: rgba(244, 114, 182, 0.1); border-color: rgba(244, 114, 182, 0.3); }
.engine-text[data-engine="Unity"] { color: #f8fafc; background: rgba(248, 250, 252, 0.1); border-color: rgba(248, 250, 252, 0.3); }
.palette-container[data-theme="light"] .engine-text[data-engine="Unity"] { color: #0f172a; border-color: rgba(15, 23, 42, 0.3); }
.engine-text[data-engine="RPG Maker"] { color: #38bdf8; background: rgba(56, 189, 248, 0.1); border-color: rgba(56, 189, 248, 0.3); }
.engine-text[data-engine="HTML/Twine"] { color: #fbbf24; background: rgba(251, 191, 36, 0.1); border-color: rgba(251, 191, 36, 0.3); }
.row-title { font-size: 15px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-main); letter-spacing: -0.01em;}
.fav-star { width: 18px; height: 18px; fill: none; stroke: var(--text-muted); stroke-width: 2; transition: 0.2s; cursor: pointer; flex-shrink: 0;}
.fav-star:hover { stroke: #fbbf24; }
.fav-star.active { fill: #fbbf24; stroke: #fbbf24; }
.row-tags { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; max-width: 40%; align-items: center; }
.chevron { width: 18px; height: 18px; fill: var(--text-muted); transition: transform 0.3s var(--glide); margin-left: 8px; opacity: 0.5;}
.list-row:hover .chevron { opacity: 1; }
.list-row.expanded .chevron { transform: rotate(180deg); opacity: 1; }
/* Compact Mode */
.results-area.compact .list-row { padding: 8px 16px; }
.results-area.compact .row-title { font-size: 13px; font-weight: 500; }
.results-area.compact .pill { font-size: 10px; padding: 1px 6px; }
/* SEMANTIC COLOR PILLS */
.pill { position: relative; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; font-family: var(--font-mono); background: transparent; border: 1px solid var(--border); color: var(--text-muted);}
.pill-blood { background: rgba(239, 68, 68, 0.1); border-color: rgba(239, 68, 68, 0.3); color: #ef4444; }
.pill-step { background: rgba(59, 130, 246, 0.1); border-color: rgba(59, 130, 246, 0.3); color: #3b82f6; }
.pill-other { background: rgba(139, 92, 246, 0.1); border-color: rgba(139, 92, 246, 0.3); color: #8b5cf6; }
.pill[data-tooltip]::after, .status-dot[data-tooltip]::after, .fab-mini-tags {
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
transform: translate3d(-50%, 10px, 0); will-change: transform, opacity;
}
.pill[data-tooltip]::after, .status-dot[data-tooltip]::after {
content: attr(data-tooltip); position: absolute; bottom: 100%; left: 50%;
background: var(--bg-solid); padding: 6px 12px; border-radius: 6px; font-family: var(--font); font-weight: 500;
font-size: 11px; white-space: nowrap; opacity: 0; pointer-events: none; transition: 0.2s var(--glide);
border: 1px solid var(--border); color: var(--text-main); z-index: 20; box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.pill[data-tooltip]:hover::after, .status-dot[data-tooltip]:hover::after { opacity: 1; transform: translate3d(-50%, -8px, 0); }
/* Bento Matrix Animations */
@keyframes bentoSlide { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.expanded-wrapper { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.4s var(--spring); }
.expanded-inner { overflow: hidden; }
.list-row.expanded .expanded-wrapper { grid-template-rows: 1fr; }
.list-row.expanded { background: var(--bg-hover); }
.bento-grid { display: flex; gap: 24px; padding: 16px 0 8px 32px; margin-top: 8px; border-top: 1px dashed var(--border); }
.bento-col { flex: 1; display: flex; flex-direction: column; gap: 8px; border-left: 1px solid var(--border); padding-left: 16px; }
.bento-col:first-child { border-left: none; padding-left: 0; }
.col-header { font-size: 10px; text-transform: uppercase; color: var(--text-muted); font-weight: 800; letter-spacing: 0.5px; margin-bottom: 4px; }
.matrix-item { display: flex; align-items: baseline; gap: 6px; font-size: 13px; color: var(--text-main); opacity: 0; }
.list-row.expanded .matrix-item { animation: bentoSlide 0.4s var(--spring) forwards; }
.item-mod { font-size: 9px; text-transform: uppercase; font-weight: 800; color: var(--text-muted); padding: 2px 4px; background: var(--border); border-radius: 3px; }
.item-mod.custom-mod { background: rgba(139, 92, 246, 0.2); color: #8b5cf6; border-color: rgba(139, 92, 246, 0.4); }
/* DEV NOTES EXTRACTOR */
.dev-note { margin-top: 16px; padding: 12px 16px; background: rgba(0,0,0,0.1); border-left: 3px solid var(--text-muted); border-radius: 0 8px 8px 0; font-size: 12px; font-style: italic; color: var(--text-muted); }
.palette-container[data-theme="light"] .dev-note { background: rgba(0,0,0,0.03); }
.omni-actions { display: flex; gap: 12px; margin-top: 12px; }
.btn-action { background: var(--bg); border: 1px solid var(--border); color: var(--text-main); padding: 8px 14px; border-radius: 8px; cursor: pointer; font-weight: 600; transition: 0.2s; font-size: 12px; text-decoration: none; display: inline-flex; align-items: center; justify-content:center;}
.btn-action:hover { background: var(--bg-hover); border-color: var(--accent); }
/* --- FOOTER --- */
.footer { display: flex; justify-content: space-between; align-items: center; padding: 12px 24px; border-top: 1px solid var(--border); background: var(--bg-solid); }
.kb-hints { font-size: 11px; color: var(--text-muted); font-family: var(--font-mono); display: flex; gap: 12px; align-items: center; font-weight: 500;}
.kb-key { background: var(--bg-hover); border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px; color: var(--text-main); font-weight: 700;}
/* --- FAB --- */
@keyframes stealth-pulse { 0% { border-color: rgba(16, 185, 129, 0.2); box-shadow: 0 0 10px rgba(0,0,0,0.5), 0 0 5px rgba(16, 185, 129, 0.1), inset 0 0 2px rgba(16, 185, 129, 0.1); } 100% { border-color: rgba(16, 185, 129, 1); box-shadow: 0 0 15px rgba(0,0,0,0.6), 0 0 15px rgba(16, 185, 129, 0.4), inset 0 0 8px rgba(16, 185, 129, 0.3); } }
@keyframes spin { 100% { transform: rotate(360deg); } }
.fab { position: fixed; bottom: 24px; right: 24px; z-index: 999999; background: rgba(10, 10, 12, 0.6); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: var(--text-muted); width: 48px; height: 48px; border-radius: 50%; display: flex; justify-content: center; align-items: center; cursor: pointer; box-shadow: 0 10px 20px rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.08); transition: 0.3s var(--glide); }
.fab span { display: none; }
.fab:hover { background: rgba(15, 15, 18, 0.8); transform: scale(1.05) translateY(-2px); border-color: rgba(255,255,255,0.2); color: var(--text-main); }
.fab svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; }
.fab.match-found { color: var(--success); animation: stealth-pulse 2s infinite alternate ease-in-out; }
.fab-mini-tags { position: absolute; bottom: 120%; right: 0; background: rgba(15, 23, 42, 0.95); border: 1px solid rgba(255,255,255,0.2); padding: 8px 14px; border-radius: 8px; font-family: var(--font-mono); font-size: 11px; white-space: nowrap; pointer-events: none; box-shadow: 0 10px 30px rgba(0,0,0,0.6); opacity: 0; display: block; color: var(--text-main); }
.fab:hover .fab-mini-tags { opacity: 1; transform: translate3d(0, 0, 0); }
.spin-icon { animation: spin 1s linear infinite; }
`;
STATE.shadowRoot.appendChild(style);
const backdrop = document.createElement('div');
backdrop.className = 'backdrop';
STATE.shadowRoot.appendChild(backdrop);
const container = document.createElement('div');
container.className = 'palette-container';
STATE.shadowRoot.appendChild(container);
STATE.uiHost = container;
STATE.backdrop = backdrop;
}
function buildExplorer() {
const root = STATE.uiHost;
applyLumaTheme(STATE.shadowRoot);
const svgSearch = `<svg class="icon-search" viewBox="0 0 24 24"><path d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`;
const svgList = `<svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`;
root.innerHTML = `
<div class="sticky-header">
<div class="search-header">
${svgSearch}
<div class="search-wrapper">
<input type="text" class="search-bar" id="real-search" placeholder="Type to search library...">
<div class="search-ghost" id="ghost-search"></div>
</div>
<div class="header-actions">
<button class="icon-btn ${SETTINGS.compactMode ? 'active' : ''}" id="btn-density" title="Toggle Compact View">${svgList}</button>
</div>
</div>
<div class="filters-row">
<button class="filter-btn" id="btn-fav-filter">★ Favorites</button>
<div class="popover-anchor">
<button class="filter-btn" id="btn-tag-menu" style="background:var(--accent); color:#fff; border:none; box-shadow:0 4px 12px var(--accent-bg);">🏷️ Tags Select</button>
<div class="tag-popover" id="tag-popover">
<div class="tm-header">
<input type="text" class="tm-search" id="tm-search" placeholder="Filter tags...">
<button class="tm-clear" id="tm-clear">Clear</button>
</div>
<div class="tm-body" id="tm-body"></div>
</div>
</div>
<div class="select-wrapper"><select id="sel-engine"><option value="">Engine: All</option></select></div>
<div class="select-wrapper"><select id="sel-status"><option value="">Status: All</option></select></div>
<div class="select-wrapper"><select id="sel-sort"><option value="">Sort: Default</option><option value="az">Sort: A-Z</option><option value="za">Sort: Z-A</option></select></div>
</div>
<div class="active-filters-container" id="active-filters" style="display:none;"></div>
</div>
<div class="results-area ${SETTINGS.compactMode ? 'compact' : ''}" id="results-area">
<div id="results-content"></div>
</div>
<div class="footer">
<div class="kb-hints">
<span class="kb-key">Tab</span> Autocomplete <span class="kb-key">↑↓</span> Nav <span class="kb-key">↵</span> Expand
</div>
<button class="btn-action" id="btn-sync" style="padding: 6px 12px; font-size:11px; border:none; opacity:0.6;">☁️ Refresh Cloud Data</button>
</div>
`;
const searchBar = root.querySelector('#real-search');
const ghostSearch = root.querySelector('#ghost-search');
const resultsArea = root.querySelector('#results-area');
const resultsContent = root.querySelector('#results-content');
const activeFiltersContainer = root.querySelector('#active-filters');
const tagPopover = root.querySelector('#tag-popover');
const tmSearch = root.querySelector('#tm-search');
const tmBody = root.querySelector('#tm-body');
const btnTagMenu = root.querySelector('#btn-tag-menu');
function closeUI() {
if (tagPopover.classList.contains('active')) {
tagPopover.classList.remove('active');
return;
}
root.classList.add('closing');
STATE.backdrop.style.opacity = '0';
setTimeout(() => {
root.classList.remove('open', 'closing');
STATE.backdrop.style.display = 'none';
}, 200);
}
STATE.backdrop.onclick = closeUI;
root.querySelector('#btn-sync').onclick = async (e) => {
const btn = e.target;
const originalText = btn.innerHTML;
btn.innerHTML = '⏳ Syncing...';
await initData();
btn.innerHTML = '✅ Synced!';
setTimeout(() => btn.innerHTML = originalText, 1500);
};
root.querySelector('#btn-density').onclick = (e) => {
SETTINGS.compactMode = !SETTINGS.compactMode;
GM_setValue('RC_Settings', JSON.stringify(SETTINGS));
e.currentTarget.classList.toggle('active', SETTINGS.compactMode);
resultsArea.classList.toggle('compact', SETTINGS.compactMode);
};
const viewState = { page: 0, filters: { search: '', tags: {}, engine: '', status: '', sort: '', favsOnly: false } };
if (STATE.dbLoaded) {
STATE.facets.engines.forEach(x => root.querySelector('#sel-engine').appendChild(new Option(x, x)));
STATE.facets.statuses.forEach(x => root.querySelector('#sel-status').appendChild(new Option(x, x)));
}
btnTagMenu.onclick = (e) => {
e.stopPropagation();
const isActive = tagPopover.classList.contains('active');
tagPopover.classList.toggle('active', !isActive);
if (!isActive) tmSearch.focus();
};
tagPopover.onclick = e => e.stopPropagation();
document.addEventListener('click', () => tagPopover.classList.remove('active'));
root.querySelector('#tm-clear').onclick = () => {
viewState.filters.tags = {};
renderTagModal(tmSearch.value);
renderActiveFilters();
viewState.page = 0; updateView();
};
function renderTagModal(filter = '') {
tmBody.innerHTML = '';
const f = filter.toLowerCase();
const grouped = { 'Immediate Family': [], 'Extended Family': [], 'Generational': [], 'Other / Custom': [] };
const currentList = getFilteredList(viewState.filters);
STATE.facets.tags.forEach(tag => {
const name = getFullTagName(tag);
if (f && !name.toLowerCase().includes(f) && !tag.toLowerCase().includes(f)) return;
const count = currentList.filter(g => g.parsedTags.some(pt => pt.base === tag)).length;
let foundGroup = 'Other / Custom';
for (const [gName, gTags] of Object.entries(TAG_GROUPS)) {
if (gTags.includes(tag)) { foundGroup = gName; break; }
}
grouped[foundGroup].push({ tag, name, count });
});
for (const [groupName, items] of Object.entries(grouped)) {
if (items.length === 0) continue;
const section = document.createElement('div');
section.innerHTML = `<div class="tm-section-title">${groupName}</div>`;
const grid = document.createElement('div');
grid.className = 'tm-grid';
items.forEach(item => {
const state = viewState.filters.tags[item.tag] || 'neutral';
const btn = document.createElement('div');
let cls = 'tm-btn';
if (state === 'include') cls += ' include';
if (state === 'exclude') cls += ' exclude';
if (item.count === 0 && state === 'neutral') cls += ' disabled';
btn.className = cls;
btn.innerHTML = `<span>${item.name}</span><span class="tm-count">${item.count}</span>`;
btn.onclick = (e) => {
e.stopPropagation();
if (state === 'neutral') viewState.filters.tags[item.tag] = 'include';
else if (state === 'include') viewState.filters.tags[item.tag] = 'exclude';
else delete viewState.filters.tags[item.tag];
renderTagModal(tmSearch.value);
renderActiveFilters();
viewState.page = 0; updateView();
};
grid.appendChild(btn);
});
section.appendChild(grid);
tmBody.appendChild(section);
}
}
tmSearch.addEventListener('input', e => renderTagModal(e.target.value));
root.querySelector('#btn-fav-filter').onclick = (e) => {
viewState.filters.favsOnly = !viewState.filters.favsOnly;
e.target.classList.toggle('active', viewState.filters.favsOnly);
viewState.page = 0; renderTagModal(tmSearch.value); updateView();
};
let longPressTimer;
resultsContent.addEventListener('mousedown', (e) => {
const engineTag = e.target.closest('.engine-text');
if (engineTag) {
longPressTimer = setTimeout(() => {
const eng = engineTag.textContent;
viewState.filters.engine = eng;
root.querySelector('#sel-engine').value = eng;
viewState.page = 0;
renderTagModal(tmSearch.value);
updateView();
}, 500);
}
});
resultsContent.addEventListener('mouseup', () => clearTimeout(longPressTimer));
resultsContent.addEventListener('mouseleave', () => clearTimeout(longPressTimer));
resultsContent.addEventListener('click', (e) => {
const favBtn = e.target.closest('.fav-star');
const copyBtn = e.target.closest('.action-copy');
const row = e.target.closest('.list-row');
if (favBtn && row) {
e.stopPropagation();
const id = row.dataset.id;
if (FAVORITES.has(id)) FAVORITES.delete(id); else FAVORITES.add(id);
GM_setValue('RC_Favorites', JSON.stringify([...FAVORITES]));
favBtn.classList.toggle('active', FAVORITES.has(id));
} else if (copyBtn) {
e.stopPropagation();
navigator.clipboard.writeText(copyBtn.dataset.text);
const oldText = copyBtn.innerHTML;
copyBtn.innerHTML = '✔ Copied!';
setTimeout(() => copyBtn.innerHTML = oldText, 2000);
} else if (row) {
row.classList.toggle('expanded');
}
});
resultsArea.addEventListener('scroll', () => {
if (STATE.isFetching) return;
if (resultsArea.scrollTop + resultsArea.clientHeight >= resultsArea.scrollHeight - 50) {
const maxPages = Math.ceil(STATE.activeTotal / CONFIG.PAGE_SIZE);
if (viewState.page + 1 < maxPages) { viewState.page++; updateView(true); }
}
});
function renderActiveFilters() {
activeFiltersContainer.innerHTML = '';
const activeKeys = Object.keys(viewState.filters.tags);
btnTagMenu.textContent = activeKeys.length > 0 ? `🏷️ Relationships (${activeKeys.length})` : '🏷️ Tags Select';
activeKeys.forEach(tag => {
const state = viewState.filters.tags[tag];
const pill = document.createElement('div');
pill.className = `pill-filter ${state}`;
pill.innerHTML = `${getFullTagName(tag)} ×`;
pill.onclick = () => {
delete viewState.filters.tags[tag];
renderTagModal(tmSearch.value);
renderActiveFilters(); viewState.page = 0; updateView();
};
activeFiltersContainer.appendChild(pill);
});
activeFiltersContainer.style.display = activeKeys.length > 0 ? 'flex' : 'none';
}
function updateView(append = false) {
if (!STATE.dbLoaded) return;
STATE.isFetching = true;
const data = runFilter(viewState.filters, viewState.page);
STATE.activeTotal = data.total;
STATE.activeData = data.items;
STATE.focusedIndex = -1;
if (!append) resultsContent.innerHTML = '';
const q = viewState.filters.search;
if (q && data.items.length > 0) {
const firstTitle = data.items[0].title;
if (firstTitle.toLowerCase().startsWith(q)) {
ghostSearch.textContent = searchBar.value + firstTitle.substring(q.length);
requestAnimationFrame(() => ghostSearch.classList.add('active'));
} else {
ghostSearch.classList.remove('active');
ghostSearch.textContent = '';
}
} else {
ghostSearch.classList.remove('active');
ghostSearch.textContent = '';
}
if (data.total === 0) {
resultsContent.innerHTML = '<div style="text-align:center; padding: 60px; color: var(--text-muted); font-size: 14px;">No results found.</div>';
STATE.isFetching = false;
return;
}
data.items.forEach((game, index) => {
const row = document.createElement('div');
row.className = `list-row`;
row.dataset.id = game._id;
row.dataset.index = (viewState.page * CONFIG.PAGE_SIZE) + index;
row.style.animationDelay = `${(index % CONFIG.PAGE_SIZE) * 0.05}s`;
if (game._id === STATE.pinnedGameId && !q && !viewState.filters.sort) {
row.classList.add('expanded');
}
const statData = getStatusData(game.cleanStatus);
const engName = game.cleanEngine;
let tagsAbbrHtml = '';
const matrixCats = { blood: [], step: [], other: [] };
game.parsedTags.forEach(pt => { matrixCats[pt.category].push(pt); });
const uniqueBases = new Map();
game.parsedTags.forEach(pt => {
if (!uniqueBases.has(pt.base)) uniqueBases.set(pt.base, pt);
else if (pt.category === 'blood' && uniqueBases.get(pt.base).category !== 'blood') uniqueBases.set(pt.base, pt);
});
Array.from(uniqueBases.values()).forEach(pt => {
const definition = DEFINITIONS[pt.modifier] || pt.full;
tagsAbbrHtml += `<span class="pill pill-${pt.category}" data-tooltip="${definition}">${pt.base.toUpperCase()}</span>`;
});
let matrixHtml = '';
if (matrixCats.blood.length) matrixHtml += renderMatrixCol('Biological', matrixCats.blood);
if (matrixCats.step.length) matrixHtml += renderMatrixCol('Non-Biological', matrixCats.step);
if (matrixCats.other.length) matrixHtml += renderMatrixCol('Other', matrixCats.other);
const actionsHtml = `
<a href="https://www.google.com/search?q=${encodeURIComponent(game.title + ' visual novel')}" target="_blank" class="btn-action">Google</a>
<a href="https://vndb.org/v/all?q=${encodeURIComponent(game.title)}" target="_blank" class="btn-action">VNDB</a>
<button class="btn-action action-copy" data-text="${game.title} - ${game.relationships}">Copy</button>
`;
const isFav = FAVORITES.has(game._id) ? 'active' : '';
const svgStarHtml = `<svg class="fav-star ${isFav}" viewBox="0 0 24 24"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" stroke-linejoin="round" stroke-linecap="round"/></svg>`;
const svgChevronHtml = `<svg class="chevron" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`;
let devNoteHtml = game.devNote ? `<div class="dev-note">💡 ${game.devNote}</div>` : '';
row.innerHTML = `
<div class="row-visible">
<div class="row-main">
${svgStarHtml}
<span class="status-dot" style="background:${statData.color}; box-shadow: 0 0 8px ${statData.color}60;" data-tooltip="${statData.label}"></span>
<span class="engine-text" data-engine="${engName}" title="Hold to isolate">${engName}</span>
<div class="row-title">${game.title}</div>
</div>
<div class="row-tags">${tagsAbbrHtml} ${svgChevronHtml}</div>
</div>
<div class="expanded-wrapper">
<div class="expanded-inner">
<div class="bento-grid">
${matrixHtml}
</div>
${devNoteHtml}
<div class="omni-actions">${actionsHtml}</div>
</div>
</div>
`;
const matrixItems = row.querySelectorAll('.matrix-item');
matrixItems.forEach((item, i) => item.style.animationDelay = `${i * 0.04}s`);
resultsContent.appendChild(row);
});
STATE.isFetching = false;
}
function renderMatrixCol(title, items) {
let html = `<div class="bento-col"><div class="col-header">${title}</div>`;
items.forEach(pt => {
const fullName = getFullTagName(pt.base);
const modText = (pt.modifier !== 'unknown' && pt.modifier !== 'others') ? pt.modifier : '';
html += `<div class="matrix-item">
${pt.isMod ? `<span class="item-mod custom-mod">MOD</span>` : ''}
${modText && !pt.isMod ? `<span class="item-mod">${modText}</span>` : ''}
<span>${fullName}</span>
</div>`;
});
return html + `</div>`;
}
function renderFocus() {
const rows = resultsContent.querySelectorAll('.list-row');
rows.forEach((r, i) => r.classList.toggle('keyboard-focus', i === STATE.focusedIndex));
if (STATE.focusedIndex >= 0 && rows[STATE.focusedIndex]) {
rows[STATE.focusedIndex].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
document.addEventListener('keydown', (e) => {
if (!root.classList.contains('open')) return;
if (tagPopover.classList.contains('active')) return;
const rows = resultsContent.querySelectorAll('.list-row');
if (e.key === 'ArrowDown') {
e.preventDefault();
STATE.focusedIndex = Math.min(STATE.focusedIndex + 1, rows.length - 1);
renderFocus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
STATE.focusedIndex = Math.max(STATE.focusedIndex - 1, 0);
renderFocus();
} else if (e.key === 'Enter' && STATE.focusedIndex >= 0) {
e.preventDefault();
const row = rows[STATE.focusedIndex];
if (row) row.classList.toggle('expanded');
}
});
// Tab Autocomplete & Shielding
['keydown', 'keyup', 'keypress'].forEach(evt => {
searchBar.addEventListener(evt, e => {
if (evt === 'keydown' && e.key === 'Tab' && ghostSearch.classList.contains('active') && ghostSearch.textContent) {
e.preventDefault();
searchBar.value = ghostSearch.textContent;
searchBar.dispatchEvent(new Event('input'));
}
e.stopPropagation();
});
});
let searchTimeout;
searchBar.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => { viewState.page = 0; viewState.filters.search = searchBar.value; updateView(); }, 150);
});
root.querySelector('#sel-engine').addEventListener('change', (e) => { viewState.page = 0; viewState.filters.engine = e.target.value; renderTagModal(tmSearch.value); updateView(); });
root.querySelector('#sel-status').addEventListener('change', (e) => { viewState.page = 0; viewState.filters.status = e.target.value; renderTagModal(tmSearch.value); updateView(); });
root.querySelector('#sel-sort').addEventListener('change', (e) => { viewState.page = 0; viewState.filters.sort = e.target.value; updateView(); });
renderActiveFilters();
renderTagModal();
updateView();
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeUI();
if (e.key.toLowerCase() === 'k' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (root.classList.contains('open')) {
closeUI();
} else if (STATE.dbLoaded) {
STATE.backdrop.style.display = 'block';
setTimeout(() => STATE.backdrop.style.opacity = '1', 10);
root.classList.add('open');
setTimeout(() => searchBar.focus(), 50);
if (STATE.facets.engines.length > 0 && root.querySelector('#sel-engine').options.length === 1) {
STATE.facets.engines.forEach(x => root.querySelector('#sel-engine').appendChild(new Option(x, x)));
STATE.facets.statuses.forEach(x => root.querySelector('#sel-status').appendChild(new Option(x, x)));
renderActiveFilters();
renderTagModal();
updateView();
}
}
}
});
}
function updateToggleButtonState() {
const btn = STATE.shadowRoot.querySelector('.fab');
if (!btn || STATE.pinnedGameId) return;
if (STATE.dbLoaded) {
btn.innerHTML = `<svg viewBox="0 0 24 24"><path d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`;
} else {
btn.innerHTML = `<svg viewBox="0 0 24 24" class="spin-icon"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-dasharray="31.4 31.4" fill="none"/></svg>`;
}
}
function addToggle() {
const btn = document.createElement('button');
btn.className = 'fab';
STATE.shadowRoot.appendChild(btn);
updateToggleButtonState();
btn.onclick = () => {
const ui = STATE.uiHost;
if (ui && ui.classList.contains('open')) {
ui.classList.add('closing');
STATE.backdrop.style.opacity = '0';
setTimeout(() => {
ui.classList.remove('open', 'closing');
STATE.backdrop.style.display = 'none';
}, 200);
} else if (STATE.dbLoaded) {
if (!ui.querySelector('.search-header')) buildExplorer();
STATE.backdrop.style.display = 'block';
setTimeout(() => STATE.backdrop.style.opacity = '1', 10);
ui.classList.add('open');
const search = ui.querySelector('#real-search');
if (search) setTimeout(() => search.focus(), 50);
if (STATE.facets.engines.length > 0 && ui.querySelector('#sel-engine').options.length === 1) {
STATE.facets.engines.forEach(x => ui.querySelector('#sel-engine').appendChild(new Option(x, x)));
STATE.facets.statuses.forEach(x => ui.querySelector('#sel-status').appendChild(new Option(x, x)));
const tmSearch = ui.querySelector('#tm-search');
if (tmSearch) tmSearch.dispatchEvent(new Event('input'));
const evt = new Event('input');
if (search) search.dispatchEvent(evt);
}
}
};
}
initUI();
addToggle();
initData();
})();